From b6700cec02851f286559e6e394a2a13352222edc Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 31 Aug 2026 13:04:11 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20DirectProject=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=AF=BC=E5=85=A5=E9=98=B6=E6=AE=B5=E4=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入客户端已启用 MCP 的隔离原生配置 保留 agc_tools 并隔离第三方 MCP 配置与启动失败 支持 STDIO 和 HTTP 标准字段及来源相对工作目录 将 MCP 集合纳入 app-server 连接池指纹 补充 Plugin MCP 样例、定向测试和现行文档 --- .../src-tauri/src/agent/codex_app_server.rs | 112 +++- .../src-tauri/src/client_extensions.rs | 488 +++++++++++++++++- .../fixtures/direct_extensions/README.md | 2 +- .../direct_extensions/expected-imports.json | 3 +- .../direct_extensions/plugin/mcp/.mcp.json | 8 + .../shared-memory/decision-log.md | 1 + .../shared-memory/project-overview.md | 2 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- ...roject客户端Skill与MCP扩展导入方案-2026-08-31.md | 8 + 10 files changed, 609 insertions(+), 21 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json 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 31dc968e1..bd1f9a90c 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 @@ -621,6 +621,12 @@ fn game_creator_codex_app_server_pool_key( } else { "disabled".to_string() }; + let client_mcp_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::enabled_client_mcp_fingerprint() + .unwrap_or_else(|_| "invalid-client-mcp".to_string()) + } else { + "disabled".to_string() + }; let stable = serde_json::json!({ "credentialFingerprint": credential_fingerprint, "baseUrl": llm.base_url, @@ -635,6 +641,7 @@ fn game_creator_codex_app_server_pool_key( "workspaceMode": workspace_mode.pool_identity(), "skillPackIdentity": skill_pack_identity, "clientSkillIdentity": client_skill_identity, + "clientMcpIdentity": client_mcp_identity, "controlledWebSearch": llm.web_search_enabled, "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, "providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, @@ -1115,11 +1122,10 @@ fn configure_game_creator_codex_app_server_command_for_mode( let controlled_web_search = workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled; command.arg("app-server").arg("--stdio"); - command - .arg("-c") - .arg("mcp_servers={}") - .arg("-c") - .arg("web_search=\"disabled\""); + if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { + command.arg("-c").arg("mcp_servers={}"); + } + command.arg("-c").arg("web_search=\"disabled\""); if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { command.arg("-c").arg("agents.enabled=false"); } @@ -1313,14 +1319,37 @@ fn prepare_isolated_game_creator_codex_home( fn trust_isolated_game_creator_codex_workspace( codex_home: &std::path::Path, workspace: &std::path::Path, + client_mcp_servers: &[crate::client_extensions::ClientMcpRuntimeServer], ) -> Result<(), platform_llm::LlmError> { - let workspace = workspace.to_string_lossy(); - let quoted_workspace = quoted_toml_string(&workspace)?; - let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n"); - std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "写入隔离 Codex app-server 项目信任配置失败:{error}" + let mut project = toml::map::Map::new(); + project.insert( + "trust_level".to_string(), + toml::Value::String("trusted".to_string()), + ); + let mut projects = toml::map::Map::new(); + projects.insert( + workspace.to_string_lossy().into_owned(), + toml::Value::Table(project), + ); + let mut root = toml::map::Map::new(); + root.insert("projects".to_string(), toml::Value::Table(projects)); + if !client_mcp_servers.is_empty() { + let mut mcp_servers = toml::map::Map::new(); + for server in client_mcp_servers { + mcp_servers.insert( + server.name.clone(), + toml::Value::Table(server.config.clone().into_iter().collect()), + ); + } + root.insert("mcp_servers".to_string(), toml::Value::Table(mcp_servers)); + } + let config = toml::to_string(&toml::Value::Table(root)).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "序列化隔离 Codex app-server 配置失败:{error}" )) + })?; + std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { + platform_llm::LlmError::Transport(format!("写入隔离 Codex app-server 配置失败:{error}")) }) } @@ -1503,8 +1532,18 @@ impl CodexAppServerConnection { .unwrap_or_else(|| isolated_workspace.clone()), ) }; + let client_mcp_servers = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::prepare_enabled_client_mcp_servers() + .map_err(platform_llm::LlmError::InvalidConfig)? + } else { + Vec::new() + }; if workspace_override.is_some() && workspace_mode.allows_workspace_writes() { - trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?; + trust_isolated_game_creator_codex_workspace( + &isolated_codex_home, + &workspace_path, + &client_mcp_servers, + )?; } let isolated_os_home = working_dir.path().join("home"); let isolated_app_data = isolated_os_home.join("appdata"); @@ -2512,6 +2551,21 @@ async fn read_game_creator_codex_app_server_stdout( .get("method") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + if method == "mcpServer/startupStatus/updated" + && inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + { + if let (Some(name), Some(status)) = ( + message + .pointer("/params/name") + .and_then(serde_json::Value::as_str), + message + .pointer("/params/status") + .and_then(serde_json::Value::as_str), + ) { + let _ = crate::client_extensions::record_client_mcp_startup_status(name, status); + } + continue; + } let safe_activity = direct_codex_safe_activity_for_notification(method); if !matches!( method, @@ -3684,6 +3738,40 @@ 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_servers={}")); + } + + #[test] + fn isolated_direct_project_config_contains_client_mcp_without_agc_tools() { + let directory = tempfile::tempdir().expect("temporary config home"); + let workspace = directory.path().join("workspace"); + std::fs::create_dir(&workspace).expect("create workspace"); + let client_mcp = crate::client_extensions::ClientMcpRuntimeServer { + name: "plugin-search".to_string(), + config: std::collections::BTreeMap::from([ + ( + "command".to_string(), + toml::Value::String("fixture-search".to_string()), + ), + ("required".to_string(), toml::Value::Boolean(false)), + ]), + }; + trust_isolated_game_creator_codex_workspace(directory.path(), &workspace, &[client_mcp]) + .expect("write isolated config"); + let config = std::fs::read_to_string(directory.path().join("config.toml")) + .expect("read isolated config"); + let parsed = config + .parse::() + .expect("parse isolated config"); + assert_eq!( + parsed["mcp_servers"]["plugin-search"]["command"].as_str(), + Some("fixture-search") + ); + assert_eq!( + parsed["mcp_servers"]["plugin-search"]["required"].as_bool(), + Some(false) + ); + assert!(parsed["mcp_servers"].get("agc_tools").is_none()); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 990bc7e63..22dd2b22f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -11,6 +11,13 @@ const CLIENT_EXTENSIONS_DIR_NAME: &str = "extensions"; const CLIENT_EXTENSIONS_INDEX_FILE_NAME: &str = "index.json"; const CLIENT_EXTENSIONS_SCHEMA_VERSION: &str = "direct-project-client-extensions.v1"; const CLIENT_EXTENSIONS_SOURCES_DIR_NAME: &str = "sources"; +const RESERVED_MCP_SERVER_NAME: &str = "agc_tools"; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ClientMcpRuntimeServer { + pub(crate) name: String, + pub(crate) config: BTreeMap, +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -513,12 +520,286 @@ fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String { format!("{:x}", digest.finalize()) } +fn client_mcp_set_fingerprint(index: &ClientExtensionIndex) -> String { + let mut entries = index + .items + .iter() + .filter(|item| item.extension_type == "mcp" && item.enabled) + .map(|item| { + ( + item.name.clone(), + item.source_relative_path.clone(), + item.fingerprint.clone(), + ) + }) + .collect::>(); + entries.sort(); + + let mut digest = Sha256::new(); + digest.update(b"direct-project-client-mcp.v1\0"); + for (name, source_relative_path, fingerprint) in entries { + for value in [name, source_relative_path, fingerprint] { + digest.update((value.len() as u64).to_le_bytes()); + digest.update(value.as_bytes()); + } + } + format!("{:x}", digest.finalize()) +} + pub(crate) fn enabled_client_skill_fingerprint() -> Result { let root = extensions_root()?; let index = read_index(&root)?; Ok(client_skill_set_fingerprint(&index)) } +pub(crate) fn enabled_client_mcp_fingerprint() -> Result { + let root = extensions_root()?; + let index = read_index(&root)?; + Ok(client_mcp_set_fingerprint(&index)) +} + +fn mcp_config_field<'a>( + config: &'a serde_json::Map, + names: &[&str], +) -> Option<&'a serde_json::Value> { + names.iter().find_map(|name| config.get(*name)) +} + +fn mcp_string_field( + config: &serde_json::Map, + names: &[&str], + label: &str, +) -> Result, String> { + let Some(value) = mcp_config_field(config, names) else { + return Ok(None); + }; + value + .as_str() + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) + .map(Some) + .ok_or_else(|| format!("MCP 配置字段 {label} 必须是非空字符串")) +} + +fn mcp_toml_field( + config: &serde_json::Map, + names: &[&str], + label: &str, +) -> Result, String> { + let Some(value) = mcp_config_field(config, names) else { + return Ok(None); + }; + toml::Value::try_from(value.clone()) + .map(Some) + .map_err(|_| format!("MCP 配置字段 {label} 无法转换为原生 TOML")) +} + +fn mcp_string_array_field( + config: &serde_json::Map, + names: &[&str], + label: &str, +) -> Result, String> { + let value = mcp_toml_field(config, names, label)?; + if value.as_ref().is_some_and(|value| { + !value + .as_array() + .is_some_and(|values| values.iter().all(|value| value.is_str())) + }) { + return Err(format!("MCP 配置字段 {label} 必须是字符串数组")); + } + Ok(value) +} + +fn mcp_string_table_field( + config: &serde_json::Map, + names: &[&str], + label: &str, +) -> Result, String> { + let value = mcp_toml_field(config, names, label)?; + if value.as_ref().is_some_and(|value| { + !value + .as_table() + .is_some_and(|values| values.values().all(|value| value.is_str())) + }) { + return Err(format!("MCP 配置字段 {label} 必须是字符串对象")); + } + Ok(value) +} + +fn is_protected_mcp_environment_name(name: &str) -> bool { + matches!( + name.trim().to_ascii_uppercase().as_str(), + "GENARRATIVE_AGC_CODEX_API_KEY" + | "GENARRATIVE_AGC_TOOL_BRIDGE_URL" + | "AGC_CONTROLLED_WEB_SEARCH_ENABLED" + | "CODEX_API_KEY" + ) +} + +fn mcp_env_vars_field( + config: &serde_json::Map, +) -> Result, String> { + let Some(value) = mcp_config_field(config, &["env_vars", "envVars"]) else { + return Ok(None); + }; + let values = value + .as_array() + .ok_or_else(|| "MCP 配置字段 env_vars 必须是数组".to_string())?; + let mut forwarded = Vec::new(); + for value in values { + let name = value + .as_str() + .or_else(|| value.get("name").and_then(serde_json::Value::as_str)) + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| "MCP 配置字段 env_vars 包含无效条目".to_string())?; + if value.as_object().is_some_and(|entry| { + entry + .get("source") + .is_some_and(|source| !matches!(source.as_str(), Some("local" | "remote"))) + }) { + return Err("MCP 配置字段 env_vars 包含无效 source".to_string()); + } + if is_protected_mcp_environment_name(name) { + continue; + } + forwarded.push( + toml::Value::try_from(value.clone()) + .map_err(|_| "MCP 配置字段 env_vars 无法转换为原生 TOML".to_string())?, + ); + } + Ok((!forwarded.is_empty()).then_some(toml::Value::Array(forwarded))) +} + +fn mcp_env_http_headers_field( + config: &serde_json::Map, +) -> Result, String> { + let Some(value) = mcp_config_field(config, &["env_http_headers", "envHttpHeaders"]) else { + return Ok(None); + }; + let headers = value + .as_object() + .ok_or_else(|| "MCP 配置字段 env_http_headers 必须是字符串对象".to_string())?; + let mut forwarded = toml::map::Map::new(); + for (header, value) in headers { + let environment_name = value + .as_str() + .ok_or_else(|| "MCP 配置字段 env_http_headers 必须是字符串对象".to_string())?; + if !is_protected_mcp_environment_name(environment_name) { + forwarded.insert( + header.clone(), + toml::Value::String(environment_name.to_string()), + ); + } + } + Ok((!forwarded.is_empty()).then_some(toml::Value::Table(forwarded))) +} + +fn resolve_mcp_cwd(source_directory: &Path, configured: Option) -> PathBuf { + configured + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + source_directory.join(path) + } + }) + .unwrap_or_else(|| source_directory.to_path_buf()) +} + +fn normalize_mcp_runtime_config( + config: &serde_json::Value, + source_directory: &Path, +) -> Result, String> { + let config = config + .as_object() + .ok_or_else(|| "MCP Server 配置必须是对象".to_string())?; + let command = mcp_string_field(config, &["command"], "command")?; + let url = mcp_string_field(config, &["url"], "url")?; + if command.is_some() == url.is_some() { + return Err("MCP Server 必须且只能配置 command 或 url".to_string()); + } + + let mut runtime = BTreeMap::new(); + if let Some(command) = command { + runtime.insert("command".to_string(), toml::Value::String(command)); + if let Some(args) = mcp_string_array_field(config, &["args"], "args")? { + runtime.insert("args".to_string(), args); + } + if let Some(env) = mcp_string_table_field(config, &["env"], "env")? { + runtime.insert("env".to_string(), env); + } + if let Some(env_vars) = mcp_env_vars_field(config)? { + runtime.insert("env_vars".to_string(), env_vars); + } + let cwd = resolve_mcp_cwd(source_directory, mcp_string_field(config, &["cwd"], "cwd")?); + runtime.insert( + "cwd".to_string(), + toml::Value::String(cwd.to_string_lossy().into_owned()), + ); + } else if let Some(url) = url { + runtime.insert("url".to_string(), toml::Value::String(url)); + if let Some(bearer_token_env_var) = mcp_string_field( + config, + &["bearer_token_env_var", "bearerTokenEnvVar"], + "bearer_token_env_var", + )? { + if !is_protected_mcp_environment_name(&bearer_token_env_var) { + runtime.insert( + "bearer_token_env_var".to_string(), + toml::Value::String(bearer_token_env_var), + ); + } + } + if let Some(headers) = mcp_string_table_field( + config, + &["http_headers", "httpHeaders", "headers"], + "http_headers", + )? { + runtime.insert("http_headers".to_string(), headers); + } + if let Some(headers) = mcp_env_http_headers_field(config)? { + runtime.insert("env_http_headers".to_string(), headers); + } + } + + for (field, aliases) in [ + ( + "startup_timeout_sec", + &["startup_timeout_sec", "startupTimeoutSec"][..], + ), + ( + "tool_timeout_sec", + &["tool_timeout_sec", "toolTimeoutSec"][..], + ), + ("enabled_tools", &["enabled_tools", "enabledTools"][..]), + ("disabled_tools", &["disabled_tools", "disabledTools"][..]), + ] { + let value = if matches!(field, "enabled_tools" | "disabled_tools") { + mcp_string_array_field(config, aliases, field)? + } else { + let value = mcp_toml_field(config, aliases, field)?; + if value + .as_ref() + .is_some_and(|value| !matches!(value, toml::Value::Integer(value) if *value >= 0)) + { + return Err(format!("MCP 配置字段 {field} 必须是非负整数")); + } + value + }; + if let Some(value) = value { + runtime.insert(field.to_string(), value); + } + } + runtime.insert("enabled".to_string(), toml::Value::Boolean(true)); + runtime.insert("required".to_string(), toml::Value::Boolean(false)); + runtime.insert( + "default_tools_approval_mode".to_string(), + toml::Value::String("approve".to_string()), + ); + Ok(runtime) +} + fn rewrite_skill_name(content: &[u8], name: &str) -> Vec { let Ok(text) = std::str::from_utf8(content) else { return content.to_vec(); @@ -646,6 +927,88 @@ pub(crate) fn prepare_enabled_client_skill_root( Ok(copied_any.then_some(client_skill_root)) } +pub(crate) fn prepare_enabled_client_mcp_servers() -> Result, String> { + let root = extensions_root()?; + let mut index = read_index(&root)?; + let mut servers = Vec::new(); + let mut index_changed = false; + + for item_index in 0..index.items.len() { + if index.items[item_index].extension_type != "mcp" || !index.items[item_index].enabled { + continue; + } + let source = index + .sources + .iter() + .find(|source| source.id == index.items[item_index].source_id) + .cloned(); + let result = (|| { + if index.items[item_index].name == RESERVED_MCP_SERVER_NAME { + return Err("MCP Server 名称 agc_tools 为客户端内置保留名称".to_string()); + } + let source = source.ok_or_else(|| "MCP Server 缺少导入来源".to_string())?; + let config = index.items[item_index] + .mcp_config + .as_ref() + .ok_or_else(|| "MCP Server 缺少原生配置".to_string())?; + let source_file = root + .join(&source.storage_path) + .join(&index.items[item_index].source_relative_path); + let source_directory = source_file + .parent() + .ok_or_else(|| "MCP Server 来源路径无效".to_string())?; + normalize_mcp_runtime_config(config, source_directory) + })(); + match result { + Ok(config) => { + if index.items[item_index].last_error.take().is_some() { + index_changed = true; + } + servers.push(ClientMcpRuntimeServer { + name: index.items[item_index].name.clone(), + config, + }); + } + Err(error) => { + if index.items[item_index].last_error.as_deref() != Some(error.as_str()) { + index.items[item_index].last_error = Some(error); + index_changed = true; + } + } + } + } + if index_changed { + write_index(&root, &index)?; + } + Ok(servers) +} + +pub(crate) fn record_client_mcp_startup_status(name: &str, status: &str) -> Result<(), String> { + if name == RESERVED_MCP_SERVER_NAME { + return Ok(()); + } + let root = extensions_root()?; + let mut index = read_index(&root)?; + let Some(item) = index + .items + .iter_mut() + .find(|item| item.extension_type == "mcp" && item.enabled && item.name == name) + else { + return Ok(()); + }; + let last_error = match status { + "failed" => Some("MCP Server 启动失败".to_string()), + "cancelled" => Some("MCP Server 启动已取消".to_string()), + "starting" | "ready" => None, + _ => return Ok(()), + }; + if item.last_error == last_error { + return Ok(()); + } + item.last_error = last_error; + write_index(&root, &index) +} + #[tauri::command] pub(crate) async fn pick_client_extension_file( app: tauri::AppHandle, @@ -794,6 +1157,10 @@ pub(crate) fn import_client_extension( .or_default() .insert(item.name.clone()); } + used_names + .entry("mcp".to_string()) + .or_default() + .insert(RESERVED_MCP_SERVER_NAME.to_string()); let mut imported = Vec::new(); let mut renamed = false; let mut duplicate = false; @@ -897,9 +1264,10 @@ pub(crate) fn rename_client_extension( return Err("扩展名称不能为空".to_string()); } let normalized = native_name(requested); - let conflict = index.items.iter().enumerate().any(|(index, item)| { - index != item_index && item.extension_type == extension_type && item.name == normalized - }); + let conflict = (extension_type == "mcp" && normalized == RESERVED_MCP_SERVER_NAME) + || index.items.iter().enumerate().any(|(index, item)| { + index != item_index && item.extension_type == extension_type && item.name == normalized + }); let final_name = if conflict { let mut names = index .items @@ -907,6 +1275,9 @@ pub(crate) fn rename_client_extension( .filter(|item| item.extension_type == extension_type) .map(|item| item.name.clone()) .collect::>(); + if extension_type == "mcp" { + names.insert(RESERVED_MCP_SERVER_NAME.to_string()); + } allocate_name(&normalized, &mut names).0 } else { normalized @@ -941,6 +1312,11 @@ mod tests { assert_eq!(allocate_name("art skill", &mut names).0, "art-skill"); assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-2"); assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-3"); + let mut mcp_names = BTreeSet::from([RESERVED_MCP_SERVER_NAME.to_string()]); + assert_eq!( + allocate_name(RESERVED_MCP_SERVER_NAME, &mut mcp_names).0, + "agc_tools-2" + ); } #[test] @@ -1034,6 +1410,112 @@ mod tests { assert_ne!(first, renamed); } + #[test] + fn runtime_mcp_config_preserves_native_transport_fields() { + let source = Path::new("installed/plugin/mcp"); + let config = serde_json::json!({ + "command": "node", + "args": ["server.js"], + "env": {"PLUGIN_TOKEN": "fixture"}, + "env_vars": ["LOCAL_TOKEN", "GENARRATIVE_AGC_CODEX_API_KEY"], + "startup_timeout_sec": 15, + "enabled": false, + "required": true + }); + let runtime = normalize_mcp_runtime_config(&config, source).expect("normalize stdio MCP"); + assert_eq!(runtime["command"], toml::Value::String("node".to_string())); + assert_eq!( + runtime["cwd"], + toml::Value::String(source.to_string_lossy().into_owned()) + ); + assert_eq!( + runtime["env_vars"], + toml::Value::Array(vec![toml::Value::String("LOCAL_TOKEN".to_string())]) + ); + assert_eq!(runtime["enabled"], toml::Value::Boolean(true)); + assert_eq!(runtime["required"], toml::Value::Boolean(false)); + assert_eq!( + runtime["default_tools_approval_mode"], + toml::Value::String("approve".to_string()) + ); + } + + #[test] + fn runtime_http_mcp_config_maps_standard_json_aliases() { + let config = serde_json::json!({ + "type": "http", + "url": "https://example.invalid/mcp", + "headers": {"X-Fixture": "value"}, + "envHttpHeaders": { + "X-Allowed": "LOCAL_TOKEN", + "Authorization": "GENARRATIVE_AGC_CODEX_API_KEY" + }, + "bearerTokenEnvVar": "GENARRATIVE_AGC_CODEX_API_KEY", + "toolTimeoutSec": 45 + }); + let runtime = normalize_mcp_runtime_config(&config, Path::new("installed/plugin")) + .expect("normalize HTTP MCP"); + assert_eq!( + runtime["url"], + toml::Value::String("https://example.invalid/mcp".to_string()) + ); + assert!(runtime.contains_key("http_headers")); + assert!(!runtime.contains_key("bearer_token_env_var")); + assert_eq!( + runtime["env_http_headers"], + toml::Value::Table(toml::map::Map::from_iter([( + "X-Allowed".to_string(), + toml::Value::String("LOCAL_TOKEN".to_string()) + )])) + ); + assert_eq!(runtime["tool_timeout_sec"], toml::Value::Integer(45)); + } + + #[test] + fn client_mcp_fingerprint_tracks_enable_and_rename() { + let mut index = ClientExtensionIndex::default(); + index.items.push(StoredExtensionItem { + id: "mcp-1".to_string(), + source_id: "source-1".to_string(), + extension_type: "mcp".to_string(), + name: "search".to_string(), + original_name: "search".to_string(), + source_relative_path: "config.toml".to_string(), + enabled: true, + fingerprint: "content-a".to_string(), + last_error: None, + mcp_config: Some(serde_json::json!({"command": "search"})), + }); + let first = client_mcp_set_fingerprint(&index); + index.items[0].enabled = false; + assert_ne!(first, client_mcp_set_fingerprint(&index)); + index.items[0].enabled = true; + index.items[0].name = "search-2".to_string(); + assert_ne!(first, client_mcp_set_fingerprint(&index)); + } + + #[test] + fn plugin_source_discovers_skill_and_mcp_as_independent_items() { + let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("direct_extensions") + .join("plugin"); + let candidates = discover_candidates(&fixture_root).expect("discover plugin fixture"); + let names = candidates + .iter() + .map(|candidate| { + ( + candidate.extension_type.as_str(), + candidate.original_name.as_str(), + ) + }) + .collect::>(); + assert_eq!(candidates.len(), 2); + assert!(names.contains(&("skill", "plugin-skill"))); + assert!(names.contains(&("mcp", "plugin-search"))); + } + #[test] fn fixture_directory_splits_independent_skill_and_mcp_items() { let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md index 421ae802e..957325e45 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md @@ -10,7 +10,7 @@ | `multi-skill/` | 2 个 Skill:`art-skill`、`code-skill` | 一个 Skill root,分别导入 | | `mcp-config/` | 2 个 MCP:`search`、`filesystem` | 原生 `config.toml` | | `mcp-json/` | 2 个 MCP:`search`、`filesystem` | 兼容 `.mcp.json` | -| `plugin/` | 1 个 Skill:`plugin-skill` | Plugin 只作为导入来源 | +| `plugin/` | 1 个 Skill:`plugin-skill`、1 个 MCP:`plugin-search` | Plugin 只作为导入来源 | | `mixed-source/` | 1 个 Skill:`mixed-skill`、2 个 MCP:`search`、`filesystem` | 一个来源拆成多个独立项 | | `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 | diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json index 01952fb67..698207aff 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json @@ -36,7 +36,8 @@ "id": "plugin", "source": "plugin", "items": [ - { "type": "skill", "name": "plugin-skill" } + { "type": "skill", "name": "plugin-skill" }, + { "type": "mcp", "name": "plugin-search" } ], "ignoredCapabilities": ["hooks", "apps", "remote_plugin"] }, diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json new file mode 100644 index 000000000..24f528bab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/plugin/mcp/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "plugin-search": { + "type": "stdio", + "command": "fixture-plugin-search" + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a34ea37ae..e541afa34 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - Plugin 边界:Plugin 只作为导入容器提取 Skill/MCP;当前 DirectProject 关闭的 hooks、apps、remote plugin 和完整 Plugin Runtime 不接入。单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。 - 信任边界:不审核第三方 Skill 文案、脚本、二进制、MCP tool 或网络行为;导入阶段不执行内容。客户端只做标准结构识别、必要配置解析和 zip staging 路径边界处理,且不向第三方扩展注入 AGC 凭据或内部路径。 - 影响范围:AGC 客户端扩展设置 UI、客户端本地扩展存储、DirectProject Codex app-server 启动准备和 pool fingerprint;不新增 HTTP 服务、SpacetimeDB schema、公开 API 或独立 Plugin Runtime。 +- 当前实现:客户端导入/list、Skill 临时 root 和 MCP 隔离配置注入均已落地。第三方 MCP 只从客户端已启用独立项生成本次隔离 `CODEX_HOME/config.toml`,每项固定非 required;配置错误或 app-server 启动状态失败只更新对应 `last_error`,内置 `agc_tools` 继续由客户端单独注入。客户端已启用 Skill/MCP 的名称、来源路径和内容指纹共同参与 DirectProject app-server pool identity。 - 验证方式:分三阶段验收:先验证导入拆分和完整列表,再验证 Skill 运行时发现和重命名,最后验证 MCP 配置合并、Plugin 提取和失败隔离;只增加对应的定向测试、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 4ee9e70b1..8190254eb 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -52,7 +52,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 ## AGC DirectProject 与 UI workflow - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。 -- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP。它负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用由客户端绑定回合、幂等账本、请求上限和投影权威。 +- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。`agc_tools` 继续负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。 - DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。 - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 14f0a4909..9e92cf2c7 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1756,6 +1756,6 @@ V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行 ## DirectProject 原生工具边界覆盖(2026-08-24) -本文中 V1.1/V1.52 关于 app-server 全局关闭 native shell、network、browser、plugin 和 multi-agent 的表述继续适用于 ToolHost/DirectHome 与 legacy Runtime;不再作为 DirectProject 的现行实现。DirectProject 恢复原生文件/搜索/命令、图片查看和 Skill,并通过隔离 `CODEX_HOME` 只注入审核后的 `agc_tools` MCP。平台美术、资源投影、浏览器试玩、受控搜索、付费副作用和 durable delegation 仍必须走 AGC 权威链路。 +本文中 V1.1/V1.52 关于 app-server 全局关闭 native shell、network、browser、plugin 和 multi-agent 的表述继续适用于 ToolHost/DirectHome 与 legacy Runtime;不再作为 DirectProject 的现行实现。DirectProject 恢复原生文件/搜索/命令、图片查看和 Skill,始终注入审核后的 `agc_tools` MCP,并可在启动时从客户端扩展仓库接入用户已启用的独立第三方 MCP 配置;第三方配置不进入全局 Codex home,不开启完整 Plugin Runtime。平台美术、资源投影、浏览器试玩、受控搜索、付费副作用和 durable delegation 仍必须走 AGC 权威链路。 DirectProject 的写入根固定为真实 `game/`,审批策略为 `never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`;shell 使用 Codex `shell_environment_policy` 的 glob 排除 API key、proxy、loopback bridge 和受控开关。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅获得连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。Codex 原生子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 以及未接入 AGC 证据链的浏览器/电脑控制保持关闭。系统提示词只传入最小身份、工作区、Skill 索引和副作用边界,不再批量注入源码快照或 Skill 正文。sandbox writableRoots 不提供 deny-read;`.agent` 与 `../assets` 的不可读约束仍需通过 prompt/Skill 行为合同和真实 smoke 验证,不能误称为 OS 强制隔离。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8211684b9..f1c6aa4a1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1188,7 +1188,7 @@ game-project/ - 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 - 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。 - `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 -- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景和 desktop/mobile 浏览器试玩。MCP 进程只做协议;真实浏览器与付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。已登记工具固定自动批准,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意网络、多 Agent、插件和外部 MCP 继续关闭。`codex_app_server` 模式要求 `llm.webSearchEnabled=false`。 +- DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP;2026-08-31 起还会在启动时接入客户端扩展仓库中用户已启用的独立第三方 STDIO/HTTP MCP 配置,但不读取用户全局 Codex MCP、不开启完整 Plugin Runtime。`agc_tools` 工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景和 desktop/mobile 浏览器试玩。内置 MCP 进程只做协议;真实浏览器与付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。内置与用户启用的第三方 MCP 工具都沿用 DirectProject 自动批准方式,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;Codex 原生 webSearch、任意原生命令网络、多 Agent 和完整插件能力继续关闭。 - 陶泥儿生成继续复用持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记;普通客户端优先使用当前 AGC 登录会话及账号路由,只有受控的 ExternalDeveloper 发布模式才在客户端内部使用按服务器 origin 隔离的私有 Key。用户和模型都不需要提供或配置 API Key;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。 - 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。 @@ -1252,7 +1252,7 @@ game-project/ 本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。 -DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、插件、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`,明确清空外部 MCP 后只注入 `agc_tools`;配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 +DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index b2dcf586a..44567e2c6 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -326,8 +326,14 @@ 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 出现在进程命令行。 + +用户导入来源中的相对 `cwd` 按该 MCP 配置文件所在目录解析;未声明 `cwd` 的 STDIO Server 默认以该目录启动,从而保留脚本参数的原生相对路径语义。客户端不替用户猜测普通文件如何启动。 + `agc_tools` 为 AGC 内部保留名称,第三方扩展不得覆盖。第三方 MCP 的工具调用继续继承 DirectProject 当前既有运行策略;本功能不新增逐工具审核系统。 +客户端不会把 AGC provider session token、工具桥地址或受控搜索标记通过 `env_vars`、`bearer_token_env_var`、`env_http_headers` 转发给第三方 MCP。扩展自身静态声明的 `env` 和 `http_headers` 仍按原生配置使用。OAuth 登录 UI、OAuth 凭据中心和自动登录不属于本阶段。 + ### 8.3 失败处理 第三方扩展的解析或启动失败时: @@ -433,6 +439,8 @@ remove_client_extension(id) ### 阶段 3:MCP 和 Plugin 部分闭环 +当前实施状态:已完成。客户端会在 DirectProject 启动前读取所有已启用 MCP 独立项,把可转换的 STDIO/HTTP 原生字段合并进本次隔离 `CODEX_HOME/config.toml`,再由现有启动参数单独注入内置 `agc_tools`。第三方项固定为非 required,结构错误或启动失败只更新对应扩展项的 `last_error`;Codex app-server 的 `mcpServer/startupStatus/updated` 通知用于清除或记录单项启动状态。已启用 MCP 的名称、来源相对路径和内容指纹已纳入 app-server pool key。Plugin 样例已覆盖同一来源中的 Skill/MCP 独立提取,不开启 Plugin Runtime。 + 完成: - `config.toml` 每个 Server 独立导入;