Skip to content

V2 PluginAppsMCP协议

从插件发现、Apps 元数据到已安装快照和 MCP 状态,理解可发现、可调用与运行时连接的边界。

基于rust-v0.150.0
CodexRustAppServerPluginMCP

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

text
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”顺序追踪,才能把发现、配置、运行时和连接状态准确对应起来。