V2 PluginAppsMCP协议
本文承接V2配置模型账户协议与MCP和MemoryCitation类型。插件、Apps 和 MCP 都会出现在客户端的“工具选择”界面,但它们代表不同层次:插件是 marketplace 发现和安装来源,App 是 connector 的公开元数据,app/installed 是已提交的运行时工具快照,MCP status 则是 server、tool、resource 和认证状态。把这四层混成一个列表,会把“能搜到”“已启用”“已经连通”和“模型可调用”误认为同一件事。
1. 协议层次
插件和 Apps 的协议类型先定义客户端能看到什么,processor 再决定当前配置、认证和缓存是否允许返回这些内容。
源码位置:codex-rs/app-server-protocol/src/protocol/v2/plugin.rs :: PluginListParams、PluginListResponse、PluginInstalledParams、PluginInstalledResponse
pub struct PluginListParams {
pub cwds: Option<Vec<AbsolutePathBuf>>,
pub marketplace_kinds: Option<Vec<PluginListMarketplaceKind>>,
pub force_refetch: bool,
}
pub struct PluginListResponse {
pub marketplaces: Vec<PluginMarketplaceEntry>,
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
pub featured_plugin_ids: Vec<String>,
}
pub struct PluginInstalledParams {
pub cwds: Option<Vec<AbsolutePathBuf>>,
pub install_suggestion_plugin_names: Option<Vec<String>>,
}
pub struct PluginInstalledResponse {
pub marketplaces: Vec<PluginMarketplaceEntry>,
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
}plugin/list 的返回值是 marketplace 集合,允许部分 marketplace 加载失败并把错误放进 marketplace_load_errors;plugin/installed 还可以接受安装建议名称,用于展示本地可安装入口。两者都不是 MCP runtime 的已连接状态。
2. Plugin列表与缓存
源码位置:codex-rs/app-server/src/request_processors/plugins.rs :: plugin_list、plugin_list_response
pub(crate) async fn plugin_list(
&self,
params: PluginListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_list_response(params)
.await
.map(|response| Some(response.into()))
}
async fn plugin_list_response(
&self,
params: PluginListParams,
) -> Result<PluginListResponse, JSONRPCErrorError> {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginListParams {
cwds,
marketplace_kinds,
force_refetch,
} = params;
let roots = cwds.unwrap_or_default();
let explicit_marketplace_kinds = marketplace_kinds.is_some();
let marketplace_kinds =
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Ok(PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
});
}默认只查 local marketplace;只有显式 kind、远程插件 feature 和认证状态共同满足时,才会追加远程目录。force_refetch 可能刷新非 curated cache,刷新后会清理插件、Skills、MCP runtime 和 hook runtime 的缓存,确保后续调用看到新的有效插件集合。
源码位置:codex-rs/app-server/src/request_processors/plugins.rs :: on_effective_plugins_changed
async fn on_effective_plugins_changed(&self) {
self.clear_plugin_related_caches();
self.thread_manager.invalidate_mcp_runtimes().await;
self.thread_manager.refresh_hook_runtimes().await;
}
fn clear_plugin_related_caches(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_service().clear_cache();
}这说明插件变更不是单纯更新 UI 列表:插件提供的 Skills、MCP server 和 hooks 都可能依赖同一个 effective plugin 集合。
3. App元数据
源码位置:codex-rs/app-server-protocol/src/protocol/v2/apps.rs :: AppsReadParams、ConnectorMetadata、AppsReadResponse
pub struct AppsReadParams {
pub app_ids: Vec<String>,
pub thread_id: Option<String>,
pub include_tools: bool,
}
pub struct ConnectorMetadata {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon_url: Option<String>,
pub icon_url_dark: Option<String>,
pub distribution_channel: Option<String>,
pub install_url: Option<String>,
pub plugin_display_names: Vec<String>,
pub tool_summaries: Option<Vec<AppToolSummary>>,
}
pub struct AppsReadResponse {
pub apps: Vec<ConnectorMetadata>,
pub missing_app_ids: Vec<String>,
}app/read 是 metadata 查询,不承诺 MCP runtime 已启动。thread_id 只用于用该线程的 effective config 判断 app 是否可见;include_tools 才会请求 display-only tool summaries。
源码位置:codex-rs/app-server/src/request_processors/apps_processor/read.rs :: AppsRequestProcessor::apps_read
pub(crate) async fn apps_read(
&self,
params: AppsReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let started_at = Instant::now();
let AppsReadParams {
app_ids,
thread_id,
include_tools,
} = params;
if app_ids.len() > APP_READ_MAX_IDS {
return Err(invalid_params(format!(
"app/read accepts at most {APP_READ_MAX_IDS} appIds"
)));
}
let mut seen_app_ids = HashSet::new();
let app_ids = app_ids
.into_iter()
.filter(|app_id| seen_app_ids.insert(app_id.clone()))
.collect::<Vec<_>>();
let config = self.load_apps_config(thread_id.as_deref()).await?;
let auth = self.auth_manager.auth().await;输入超过 100 个 id 会在访问 connector backend 前失败;重复 id 会按第一次出现的顺序去重。若 Apps feature 对当前认证不可用,processor 返回空 apps 和完整 missing_app_ids,这与“backend 没找到 app”是不同结果。
4. Installed快照
源码位置:codex-rs/app-server-protocol/src/protocol/v2/apps.rs :: AppsInstalledParams、InstalledApp、AppsInstalledResponse
pub struct AppsInstalledParams {
pub thread_id: Option<String>,
pub force_refresh: bool,
}
pub struct InstalledApp {
pub id: String,
pub runtime_name: Option<String>,
pub enabled: bool,
pub callable: bool,
}
pub struct AppsInstalledResponse {
pub apps: Vec<InstalledApp>,
}enabled 和 callable 来自已提交的工具快照与 effective app policy。一个 app 可以存在于快照中但不可调用,例如配置关闭、工具是 synthetic 或模型不可见。
源码位置:codex-rs/app-server/src/request_processors/apps_processor/installed.rs :: apps_installed
pub(crate) async fn apps_installed(
&self,
params: AppsInstalledParams,
) -> Result<AppsInstalledResponse, JSONRPCErrorError> {
let started_at = Instant::now();
let force_refresh = params.force_refresh;
let mut retained_previous_snapshot = false;
let mut refresh_disposition = if force_refresh {
"not_started"
} else {
"not_requested"
};
let result = async {
let config = self.load_apps_config(params.thread_id.as_deref()).await?;
let auth = self.auth_manager.auth().await;
let runtime_enabled = config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend));
let mcp_manager = self.thread_manager.mcp_manager();
let mut mcp_config = mcp_manager.runtime_config(&config).await;
mcp_config.permission_profile = PermissionProfile::default();
let previous_snapshot = mcp_manager
.codex_apps_tools_cache()
.current_snapshot(config.codex_home.to_path_buf(), connector_runtime_context_key(auth.as_ref()));Installed 查询使用独立的默认 permission profile,因为它是管理面读取,不属于某个活动 Turn 或 reviewer。force_refresh 时会启动 host-owned MCP runtime;刷新成功才发布新快照,失败则返回错误,同时记录是否存在可供诊断的旧快照。
5. app/list并发加载
源码位置:codex-rs/app-server/src/request_processors/apps_processor.rs :: apps_list_inner、apps_list_task
pub(crate) async fn apps_list(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.apps_list_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
async fn apps_list_inner(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let thread = if let Some(thread_id) = params.thread_id.as_deref() {
let (_, loaded_thread) = self.load_thread(thread_id).await?;
Some(loaded_thread)
} else {
None
};
let fallback_cwd = thread
.as_ref()
.map(|thread| thread.config_snapshot())
.map(|snapshot| snapshot.cwd().to_path_buf());
let mut config = self.load_latest_config(fallback_cwd).await?;app/list 不是简单读取内存数组:它可以并发读取 accessible connectors 与 directory connectors,再合并并按 cursor 分页。首次响应可能使用缓存,force_refetch 还会在两路加载完成前发送中间更新;当 hosted Apps 尚未 ready 时,任务会自动再执行一次强制刷新。
6. MCP状态
源码位置:codex-rs/app-server-protocol/src/protocol/v2/mcp.rs :: ListMcpServerStatusParams、McpServerStatus、McpServerToolCallParams、McpServerToolCallResponse
pub struct ListMcpServerStatusParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
pub detail: Option<McpServerStatusDetail>,
pub thread_id: Option<String>,
}
pub struct McpServerStatus {
pub name: String,
pub runtime_status: Option<McpServerConnectionStatus>,
pub plugin_id: Option<String>,
pub server_info: Option<McpServerInfo>,
pub tools: HashMap<String, McpTool>,
pub resources: Vec<McpResource>,
pub resource_templates: Vec<McpResourceTemplate>,
pub auth_status: McpAuthStatus,
}
pub struct McpServerToolCallParams {
pub thread_id: String,
pub server: String,
pub tool: String,
pub arguments: Option<JsonValue>,
pub meta: Option<JsonValue>,
}
pub struct McpServerToolCallResponse {
pub content: Vec<JsonValue>,
pub structured_content: Option<JsonValue>,
pub is_error: Option<bool>,
pub meta: Option<JsonValue>,
}状态查询可以选择 Full 或 ToolsAndAuthOnly,并将线程 runtime connection status 与静态 server inventory 合并。工具调用则必须带 thread id,结果同时允许普通 content、structured content、错误标志和 _meta。
源码位置:codex-rs/app-server/src/request_processors/mcp_processor.rs :: list_mcp_server_status_response
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly,
};
let snapshot = collect_mcp_server_status_snapshot_with_detail(
&mcp_config,
auth.as_ref(),
request_id,
runtime_context,
mcp_manager.codex_apps_tools_cache(),
mcp_manager.tool_catalog_cache(),
detail,
)
.await;
let runtime_statuses = match thread {
Some(thread) => thread.mcp_connection_statuses(&mcp_config).await,
None => HashMap::new(),
};最终 server 名称集合来自静态配置、runtime status、auth status、resources 和 templates 的并集,再排序、去重后分页。因此“没有工具但仍列出 server”是合法状态,可能表示连接存在但 inventory 尚未返回,或该 server 只有 resource。
7. OAuth与刷新
源码位置:codex-rs/app-server/src/request_processors/mcp_processor.rs :: mcp_server_oauth_login_response
let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
let Some(server) = effective_servers.get(&name) else {
return Err(invalid_request(format!(
"No MCP server named '{name}' found."
)));
};
let redirect_mode = if server.is_agent_plugin() {
StreamableHttpRedirectMode::AgentPluginV1
} else {
StreamableHttpRedirectMode::Legacy
};
let server = server.config();
let (url, http_headers, env_http_headers) = match &server.transport {
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
..
} => (url.clone(), http_headers.clone(), env_http_headers.clone()),
_ => {
return Err(invalid_request(
"OAuth login is only supported for streamable HTTP servers.",
));
}
};MCP OAuth 先按 thread 或全局配置解析 effective server,再依据 agent plugin 选择 redirect mode;stdio 等非 streamable HTTP server 在开始 OAuth discovery 前就被拒绝。登录完成后,后台任务会让 MCP runtime 失效,以便下一次状态读取重新建立连接。
8. 测试与边界
源码位置:codex-rs/app-server/tests/suite/v2/plugin_list.rs :: plugin_list_skips_invalid_marketplace_file_and_reports_error、plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load、plugin_list_force_refetch_waits_for_same_path_local_plugin_upgrade、plugin_list_includes_install_and_enabled_state_from_config
源码位置:codex-rs/app-server/tests/suite/v2/app_read.rs :: app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata、app_read_refetches_metadata_only_cache_entries_when_tools_are_requested、app_read_rejects_more_than_one_hundred_input_ids
源码位置:codex-rs/app-server/tests/suite/v2/app_installed.rs :: installed_apps_force_refresh_only_refreshes_tools_snapshot、installed_apps_failed_force_refresh_retains_previous_snapshot、installed_apps_thread_id_uses_effective_thread_config
源码位置:codex-rs/app-server/tests/suite/v2/mcp_server_status.rs :: mcp_server_status_list_returns_raw_server_and_tool_names、mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls、oauth_login_rejects_servers_disabled_by_managed_requirements
cd codex-rs
cargo test -p codex-app-server plugin_list_skips_invalid_marketplace_file_and_reports_error -- --nocapture --test-threads=1
cargo test -p codex-app-server plugin_list_force_refetch_waits_for_same_path_local_plugin_upgrade -- --nocapture --test-threads=1
cargo test -p codex-app-server app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata -- --nocapture --test-threads=1
cargo test -p codex-app-server app_read_rejects_more_than_one_hundred_input_ids -- --nocapture --test-threads=1
cargo test -p codex-app-server installed_apps_failed_force_refresh_retains_previous_snapshot -- --nocapture --test-threads=1
cargo test -p codex-app-server mcp_server_status_list_returns_raw_server_and_tool_names -- --nocapture --test-threads=1
cargo test -p codex-app-server mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls -- --nocapture --test-threads=1
cargo test -p codex-app-server oauth_login_rejects_servers_disabled_by_managed_requirements -- --nocapture --test-threads=1这些测试分别断言 marketplace 局部失败是否保留有效结果、强制刷新是否等待升级、app id 去重和上限、旧 runtime snapshot 的保留、MCP 状态详情级别以及 managed requirements 对 OAuth 的拒绝。它们不能证明所有远程 catalog、OAuth provider、平台 transport 或真实 connector backend 永远在线。
9. 源码定位练习
遇到“插件能搜索但工具不可调用”,依次检查 Plugins feature、marketplace kind、插件 enabled 状态、Apps runtime snapshot 的 callable,最后看 MCP server 的 runtime_status、auth 和 tool inventory。遇到 app/read 返回 metadata 但 app/installed 为空,这是两个不同缓存和权限路径的正常差异。遇到 MCP status 没有 tools,也要检查 server 是否只有 resources,或是否选择了 ToolsAndAuthOnly 之外的 inventory 路径。
阅读这组代码时,始终沿“协议类型 → processor 前置条件 → cache/runtime owner → notification 或 response”顺序追踪,才能把发现、配置、运行时和连接状态准确对应起来。
