mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
## Why
Selected plugin metadata is stable, but MCP processes are live runtime
state. They need different lifetimes:
- the MCP extension caches manifest, MCP, and connector declarations for
each stable selected root;
- each model step projects that cached metadata through the roots that
resolved as ready for that exact step;
- the MCP manager is rebuilt only when that availability projection
changes.
This matches executor skills: both features consume the same resolved
step roots instead of inferring readiness from the turn's selected
environments.
## Behavior
```text
E1 not ready for this step
-> no E1 MCP servers or connectors
-> cached plugin metadata stays in ext/mcp
E1 becomes ready
-> reuse cached metadata
-> publish one MCP runtime containing E1 capabilities
same ready roots on the next step
-> reuse the exact runtime; no rediscovery and no MCP restart
resume
-> create new extension thread state and a new MCP runtime
```
All model-facing consumers use the same step snapshot:
```text
resolved selected roots
|
v
extension MCP/connector projection
|
v
{ MCP config, connector snapshot, MCP manager }
|
+-> advertise model tools
+-> build app/connector tools
+-> execute MCP calls
```
## Cache contract
The existing MCP extension owns a cache keyed by the full
`SelectedCapabilityRoot`:
```rust
let state = thread_store.get_or_init(SelectedExecutorPluginMcpState::default);
```
The cache lives with extension thread state. Environment availability
filters projection but does not invalidate metadata. Resume creates new
thread state. There is no file watcher or executor generation because
contents behind a stable environment/root are assumed stable.
## What changes
- Keeps executor plugin discovery and cached metadata in `ext/mcp`.
- Caches MCP and connector declarations together per selected root.
- Uses the step's already-resolved capability roots, including lazy
environments that are not turn environments.
- Reuses the current MCP runtime when the ready-root projection is
unchanged.
- Uses the same step MCP manager and connector snapshot for
model-visible tools and execution.
- Resolves direct thread-scoped MCP requests from the current
selected-root projection.
## Deliberately out of scope
- `app/list` remains based on the latest global host-plugin state; this
PR does not make its response or notifications thread-specific.
- `required = true` startup semantics do not apply to delayed executor
MCP activation.
- No filesystem/content invalidation.
- No transport-disconnect watcher.
- No executor generations or environment replacement semantics.
- No client sharing across complete manager replacements.
## Stack
1. Extension-owned World State sections.
2. Project executor skills through World State.
3. Pin one MCP runtime to each model step.
4. **This PR:** project selected MCP and connector state from
extension-owned metadata.
5. Integration coverage for selected capability availability and resume.
## Verification
-
`selected_plugin_servers_use_managed_requirements_for_the_selected_root_id`
- The stacked integration PR covers unavailable to ready activation,
unchanged-runtime reuse, skills, MCP tools, connector attribution, and
cold resume.
501 lines
17 KiB
Rust
501 lines
17 KiB
Rust
use super::*;
|
|
|
|
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct McpRequestProcessor {
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
config_manager: ConfigManager,
|
|
}
|
|
|
|
impl McpRequestProcessor {
|
|
pub(crate) fn new(
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
config_manager: ConfigManager,
|
|
) -> Self {
|
|
Self {
|
|
auth_manager,
|
|
thread_manager,
|
|
outgoing,
|
|
config_manager,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn mcp_server_oauth_login(
|
|
&self,
|
|
params: McpServerOauthLoginParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.mcp_server_oauth_login_response(params)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
pub(crate) async fn mcp_server_refresh(
|
|
&self,
|
|
params: Option<()>,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.mcp_server_refresh_response(params)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
pub(crate) async fn mcp_server_status_list(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ListMcpServerStatusParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.list_mcp_server_status(request_id, params)
|
|
.await
|
|
.map(|()| None)
|
|
}
|
|
|
|
pub(crate) async fn mcp_resource_read(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: McpResourceReadParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.read_mcp_resource(request_id, params)
|
|
.await
|
|
.map(|()| None)
|
|
}
|
|
|
|
pub(crate) async fn mcp_server_tool_call(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: McpServerToolCallParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.call_mcp_server_tool(request_id, params)
|
|
.await
|
|
.map(|()| None)
|
|
}
|
|
|
|
async fn mcp_server_refresh_response(
|
|
&self,
|
|
_params: Option<()>,
|
|
) -> Result<McpServerRefreshResponse, JSONRPCErrorError> {
|
|
crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager)
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?;
|
|
Ok(McpServerRefreshResponse {})
|
|
}
|
|
|
|
async fn load_latest_config(
|
|
&self,
|
|
fallback_cwd: Option<PathBuf>,
|
|
) -> Result<Config, JSONRPCErrorError> {
|
|
self.config_manager
|
|
.load_latest_config(fallback_cwd)
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
|
|
}
|
|
|
|
async fn load_thread(
|
|
&self,
|
|
thread_id: &str,
|
|
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
|
|
let thread_id = ThreadId::from_string(thread_id)
|
|
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
|
|
|
|
let thread = self
|
|
.thread_manager
|
|
.get_thread(thread_id)
|
|
.await
|
|
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
|
|
|
|
Ok((thread_id, thread))
|
|
}
|
|
|
|
async fn mcp_server_oauth_login_response(
|
|
&self,
|
|
params: McpServerOauthLoginParams,
|
|
) -> Result<McpServerOauthLoginResponse, JSONRPCErrorError> {
|
|
let McpServerOauthLoginParams {
|
|
name,
|
|
thread_id,
|
|
scopes,
|
|
timeout_secs,
|
|
} = params;
|
|
|
|
let auth = self.auth_manager.auth().await;
|
|
let (mcp_config, runtime_context) = match thread_id.as_deref() {
|
|
Some(thread_id) => {
|
|
let (_, thread) = self.load_thread(thread_id).await?;
|
|
let runtime = thread.current_mcp_runtime().await;
|
|
(runtime.config().clone(), runtime.runtime_context().clone())
|
|
}
|
|
None => {
|
|
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
let mcp_config = self
|
|
.thread_manager
|
|
.mcp_manager()
|
|
.runtime_config(&config)
|
|
.await;
|
|
let runtime_context = McpRuntimeContext::new(
|
|
self.thread_manager.environment_manager(),
|
|
config.cwd.to_path_buf(),
|
|
);
|
|
(mcp_config, runtime_context)
|
|
}
|
|
};
|
|
let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
|
|
let Some(server) = effective_servers
|
|
.get(&name)
|
|
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
|
|
else {
|
|
return Err(invalid_request(format!(
|
|
"No MCP server named '{name}' found."
|
|
)));
|
|
};
|
|
|
|
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.",
|
|
));
|
|
}
|
|
};
|
|
|
|
let http_client = runtime_context
|
|
.resolve_http_client(&name, server)
|
|
.map_err(|err| {
|
|
internal_error(format!("failed to resolve MCP server runtime: {err}"))
|
|
})?;
|
|
|
|
let discovered_scopes = if scopes.is_none() && server.scopes.is_none() {
|
|
discover_supported_scopes_with_http_client(&server.transport, Arc::clone(&http_client))
|
|
.await
|
|
} else {
|
|
None
|
|
};
|
|
let resolved_scopes =
|
|
resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes);
|
|
|
|
let handle = perform_oauth_login_return_url_with_http_client(
|
|
&name,
|
|
&url,
|
|
mcp_config.mcp_oauth_credentials_store_mode,
|
|
mcp_config.auth_keyring_backend_kind,
|
|
http_headers,
|
|
env_http_headers,
|
|
&resolved_scopes.scopes,
|
|
server.oauth_client_id(),
|
|
server.oauth_resource.as_deref(),
|
|
timeout_secs,
|
|
mcp_config.mcp_oauth_callback_port,
|
|
mcp_config.mcp_oauth_callback_url.as_deref(),
|
|
http_client,
|
|
)
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?;
|
|
let authorization_url = handle.authorization_url().to_string();
|
|
let notification_name = name.clone();
|
|
let notification_thread_id = thread_id;
|
|
let outgoing = Arc::clone(&self.outgoing);
|
|
|
|
tokio::spawn(async move {
|
|
let (success, error) = match handle.wait().await {
|
|
Ok(()) => (true, None),
|
|
Err(err) => (false, Some(err.to_string())),
|
|
};
|
|
|
|
let notification = ServerNotification::McpServerOauthLoginCompleted(
|
|
McpServerOauthLoginCompletedNotification {
|
|
name: notification_name,
|
|
thread_id: notification_thread_id,
|
|
success,
|
|
error,
|
|
},
|
|
);
|
|
outgoing.send_server_notification(notification).await;
|
|
});
|
|
|
|
Ok(McpServerOauthLoginResponse { authorization_url })
|
|
}
|
|
|
|
async fn list_mcp_server_status(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ListMcpServerStatusParams,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
let request = request_id.clone();
|
|
|
|
let outgoing = Arc::clone(&self.outgoing);
|
|
let (config, thread) = match params.thread_id.as_deref() {
|
|
Some(thread_id) => {
|
|
let (_, thread) = self.load_thread(thread_id).await?;
|
|
let thread_config = thread.config().await;
|
|
let config = self
|
|
.config_manager
|
|
.load_latest_config_for_thread(thread_config.as_ref())
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?;
|
|
(config, Some(thread))
|
|
}
|
|
None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None),
|
|
};
|
|
let mcp_manager = self.thread_manager.mcp_manager();
|
|
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
|
|
let auth = self.auth_manager.auth().await;
|
|
let (mcp_config, runtime_context) = match thread {
|
|
Some(thread) => {
|
|
let mcp_config = thread.runtime_mcp_config(&config).await;
|
|
let runtime = thread.current_mcp_runtime().await;
|
|
(mcp_config, runtime.runtime_context().clone())
|
|
}
|
|
None => {
|
|
let mcp_config = mcp_manager.runtime_config(&config).await;
|
|
let runtime_context = McpRuntimeContext::new(
|
|
self.thread_manager.environment_manager(),
|
|
config.cwd.to_path_buf(),
|
|
);
|
|
(mcp_config, runtime_context)
|
|
}
|
|
};
|
|
|
|
tokio::spawn(async move {
|
|
Self::list_mcp_server_status_task(
|
|
outgoing,
|
|
request,
|
|
params,
|
|
mcp_config,
|
|
auth,
|
|
runtime_context,
|
|
codex_apps_tools_cache,
|
|
)
|
|
.await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_mcp_server_status_task(
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
request_id: ConnectionRequestId,
|
|
params: ListMcpServerStatusParams,
|
|
mcp_config: codex_mcp::McpConfig,
|
|
auth: Option<CodexAuth>,
|
|
runtime_context: McpRuntimeContext,
|
|
codex_apps_tools_cache: codex_mcp::CodexAppsToolsCache,
|
|
) {
|
|
let result = Self::list_mcp_server_status_response(
|
|
request_id.request_id.to_string(),
|
|
params,
|
|
mcp_config,
|
|
auth,
|
|
runtime_context,
|
|
codex_apps_tools_cache,
|
|
)
|
|
.await;
|
|
outgoing.send_result(request_id, result).await;
|
|
}
|
|
|
|
async fn list_mcp_server_status_response(
|
|
request_id: String,
|
|
params: ListMcpServerStatusParams,
|
|
mcp_config: codex_mcp::McpConfig,
|
|
auth: Option<CodexAuth>,
|
|
runtime_context: McpRuntimeContext,
|
|
codex_apps_tools_cache: codex_mcp::CodexAppsToolsCache,
|
|
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
|
|
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,
|
|
codex_apps_tools_cache,
|
|
detail,
|
|
)
|
|
.await;
|
|
|
|
let McpServerStatusSnapshot {
|
|
server_infos,
|
|
tools_by_server,
|
|
resources,
|
|
resource_templates,
|
|
auth_statuses,
|
|
mut server_names,
|
|
} = snapshot;
|
|
server_names.extend(
|
|
auth_statuses
|
|
.keys()
|
|
.cloned()
|
|
.chain(resources.keys().cloned())
|
|
.chain(resource_templates.keys().cloned()),
|
|
);
|
|
server_names.sort();
|
|
server_names.dedup();
|
|
|
|
let total = server_names.len();
|
|
let limit = params.limit.unwrap_or(total as u32).max(1) as usize;
|
|
let effective_limit = limit.min(total);
|
|
let start = match params.cursor {
|
|
Some(cursor) => match cursor.parse::<usize>() {
|
|
Ok(idx) => idx,
|
|
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
|
|
},
|
|
None => 0,
|
|
};
|
|
|
|
if start > total {
|
|
return Err(invalid_request(format!(
|
|
"cursor {start} exceeds total MCP servers {total}"
|
|
)));
|
|
}
|
|
|
|
let end = start.saturating_add(effective_limit).min(total);
|
|
|
|
let data: Vec<McpServerStatus> = server_names[start..end]
|
|
.iter()
|
|
.map(|name| McpServerStatus {
|
|
name: name.clone(),
|
|
server_info: server_infos.get(name).cloned(),
|
|
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
|
|
resources: resources.get(name).cloned().unwrap_or_default(),
|
|
resource_templates: resource_templates.get(name).cloned().unwrap_or_default(),
|
|
auth_status: auth_statuses
|
|
.get(name)
|
|
.cloned()
|
|
.unwrap_or(CoreMcpAuthStatus::Unsupported)
|
|
.into(),
|
|
})
|
|
.collect();
|
|
|
|
let next_cursor = if end < total {
|
|
Some(end.to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ListMcpServerStatusResponse { data, next_cursor })
|
|
}
|
|
|
|
async fn read_mcp_resource(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: McpResourceReadParams,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
let outgoing = Arc::clone(&self.outgoing);
|
|
let McpResourceReadParams {
|
|
thread_id,
|
|
server,
|
|
uri,
|
|
} = params;
|
|
|
|
if let Some(thread_id) = thread_id {
|
|
let (_, thread) = self.load_thread(&thread_id).await?;
|
|
let request_id = request_id.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let result = thread.read_mcp_resource(&server, &uri).await;
|
|
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
|
|
});
|
|
return Ok(());
|
|
}
|
|
|
|
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
let mcp_manager = self.thread_manager.mcp_manager();
|
|
let mcp_config = mcp_manager.runtime_config(&config).await;
|
|
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
|
|
let auth = self.auth_manager.auth().await;
|
|
let environment_manager = self.thread_manager.environment_manager();
|
|
// This threadless resource-read path has no turn cwd or turn-selected
|
|
// environment. Use config cwd only as the local stdio fallback; named
|
|
// environment stdio MCPs must declare their own absolute cwd.
|
|
let runtime_context =
|
|
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
|
|
let request_id = request_id.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let result = read_mcp_resource_without_thread(
|
|
&mcp_config,
|
|
auth.as_ref(),
|
|
runtime_context,
|
|
codex_apps_tools_cache,
|
|
&server,
|
|
&uri,
|
|
)
|
|
.await
|
|
.and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from));
|
|
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_mcp_resource_read_response(
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
request_id: ConnectionRequestId,
|
|
result: anyhow::Result<serde_json::Value>,
|
|
) {
|
|
let result = result
|
|
.map_err(|error| internal_error(format!("{error:#}")))
|
|
.and_then(|result| {
|
|
serde_json::from_value::<McpResourceReadResponse>(result).map_err(|error| {
|
|
internal_error(format!(
|
|
"failed to deserialize MCP resource read response: {error}"
|
|
))
|
|
})
|
|
});
|
|
outgoing.send_result(request_id, result).await;
|
|
}
|
|
|
|
async fn call_mcp_server_tool(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: McpServerToolCallParams,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
let outgoing = Arc::clone(&self.outgoing);
|
|
let thread_id = params.thread_id.clone();
|
|
let (_, thread) = self.load_thread(&thread_id).await?;
|
|
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
|
|
let request_id = request_id.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let result = thread
|
|
.call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta)
|
|
.await
|
|
.map(McpServerToolCallResponse::from)
|
|
.map_err(|error| internal_error(format!("{error:#}")));
|
|
outgoing.send_result(request_id, result).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn with_mcp_tool_call_thread_id_meta(
|
|
meta: Option<serde_json::Value>,
|
|
thread_id: &str,
|
|
) -> Option<serde_json::Value> {
|
|
match meta {
|
|
Some(serde_json::Value::Object(mut map)) => {
|
|
map.insert(
|
|
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(thread_id.to_string()),
|
|
);
|
|
Some(serde_json::Value::Object(map))
|
|
}
|
|
None => {
|
|
let mut map = serde_json::Map::new();
|
|
map.insert(
|
|
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(thread_id.to_string()),
|
|
);
|
|
Some(serde_json::Value::Object(map))
|
|
}
|
|
other => other,
|
|
}
|
|
}
|