mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add MCP event discovery and subscriptions (#37494)
## What changed - Expose hosted Plugin Runtime event definitions through `McpResourceClient::list_events`. - Add cancellable `events/stream` subscriptions that route lifecycle notifications to the matching request and cancel when the stream is dropped. - Bound event notification and queue sizes, time out stalled response headers, and close streamable HTTP responses locally on cancellation. ## Testing - Add streamable HTTP integration coverage for isolated concurrent subscriptions, local cancellation, and stalled response-header timeouts. GitOrigin-RevId: 98c6fffa8f004eb2638c1b0705f659222cbbdfdb
This commit is contained in:
@@ -156,7 +156,10 @@ impl McpConnectionSet {
|
||||
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
|
||||
}
|
||||
|
||||
async fn client_by_name(&self, name: &str) -> Result<(ManagedClient, Option<Duration>)> {
|
||||
pub(crate) async fn client_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<(ManagedClient, Option<Duration>)> {
|
||||
let view = self
|
||||
.servers
|
||||
.get(name)
|
||||
|
||||
@@ -7,6 +7,10 @@ pub use elicitation::ElicitationLifecycle;
|
||||
pub use elicitation::ElicitationReviewRequest;
|
||||
pub use elicitation::ElicitationReviewer;
|
||||
pub use elicitation::ElicitationReviewerHandle;
|
||||
pub use resource_client::McpEventCatalogSnapshot;
|
||||
pub use resource_client::McpEventDefinition;
|
||||
pub use resource_client::McpEventNotification;
|
||||
pub use resource_client::McpEventStream;
|
||||
pub use resource_client::McpResourceClient;
|
||||
pub use resource_client::McpResourceClientCacheKey;
|
||||
pub use resource_client::McpResourcePage;
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_protocol::mcp::Resource;
|
||||
use codex_protocol::mcp::ResourceContent;
|
||||
use codex_rmcp_client::CancellableEventStreamRequest;
|
||||
use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ServerResult;
|
||||
use rmcp::service::ServiceError;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::McpRuntime;
|
||||
use crate::connection_manager::McpConnectionSet;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
|
||||
/// One page of resources returned by an MCP server.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -27,7 +38,105 @@ pub struct McpResourceReadResult {
|
||||
pub contents: Vec<ResourceContent>,
|
||||
}
|
||||
|
||||
/// Access to MCP resources through the latest runtime.
|
||||
/// An event advertised by an MCP server.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpEventDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub delivery: Vec<String>,
|
||||
pub input_schema: Value,
|
||||
pub payload_schema: Value,
|
||||
}
|
||||
|
||||
/// Events returned from one stable MCP connection generation.
|
||||
pub struct McpEventCatalogSnapshot {
|
||||
pub cache_key: McpResourceClientCacheKey,
|
||||
pub events: Vec<McpEventDefinition>,
|
||||
}
|
||||
|
||||
/// One unmodified lifecycle notification from an MCP event subscription.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McpEventNotification {
|
||||
pub method: String,
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
/// Owns an MCP event subscription and cancels its request when dropped.
|
||||
pub struct McpEventStream {
|
||||
request: Option<CancellableEventStreamRequest>,
|
||||
runtime_handle: Handle,
|
||||
_connections: Arc<McpConnectionSet>,
|
||||
}
|
||||
|
||||
impl McpEventStream {
|
||||
/// Receives the next raw lifecycle notification for this subscription.
|
||||
pub async fn recv(&mut self) -> Result<Option<McpEventNotification>> {
|
||||
let Some(request) = self.request.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
notification = request.notifications.recv() => {
|
||||
match notification {
|
||||
Some(notification) => Ok(Some(McpEventNotification {
|
||||
method: notification.method,
|
||||
params: notification.params,
|
||||
})),
|
||||
None => {
|
||||
let response = (&mut request.handle.rx).await;
|
||||
self.request = None;
|
||||
match response {
|
||||
Ok(Ok(_)) | Ok(Err(ServiceError::Cancelled { .. })) => Ok(None),
|
||||
Ok(Err(error)) => Err(error.into()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response = &mut request.handle.rx => {
|
||||
self.request = None;
|
||||
|
||||
match response {
|
||||
Ok(Ok(_)) | Ok(Err(ServiceError::Cancelled { .. })) => Ok(None),
|
||||
Ok(Err(error)) => Err(error.into()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpEventStream {
|
||||
fn drop(&mut self) {
|
||||
if let Some(CancellableEventStreamRequest {
|
||||
handle,
|
||||
notifications,
|
||||
}) = self.request.take()
|
||||
{
|
||||
drop(notifications);
|
||||
let connections = Arc::clone(&self._connections);
|
||||
self.runtime_handle.spawn(async move {
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
handle.cancel(Some("event subscription closed".to_string())),
|
||||
)
|
||||
.await;
|
||||
drop(connections);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpEventListResult {
|
||||
events: Vec<McpEventDefinition>,
|
||||
}
|
||||
|
||||
/// Access to MCP resources and event subscriptions through the latest runtime.
|
||||
#[derive(Clone)]
|
||||
pub struct McpResourceClient {
|
||||
runtime: Arc<McpRuntime>,
|
||||
@@ -110,6 +219,63 @@ impl McpResourceClient {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(McpResourceReadResult { contents })
|
||||
}
|
||||
|
||||
/// Lists the events advertised by the hosted Plugin Runtime.
|
||||
pub async fn list_events(&self) -> Result<McpEventCatalogSnapshot> {
|
||||
let connections = self.runtime.latest_connections();
|
||||
let cache_key = McpResourceClientCacheKey(Arc::downgrade(&connections));
|
||||
let (managed, request_timeout) = connections
|
||||
.client_by_name(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.await?;
|
||||
let result = managed
|
||||
.client
|
||||
.send_custom_request_with_timeout("events/list", /*params*/ None, request_timeout)
|
||||
.await
|
||||
.context("events/list failed for hosted Plugin Runtime")?;
|
||||
let ServerResult::CustomResult(result) = result else {
|
||||
return Err(anyhow!("events/list returned an unexpected MCP result"));
|
||||
};
|
||||
let result = result
|
||||
.result_as::<McpEventListResult>()
|
||||
.context("events/list returned invalid event definitions")?;
|
||||
|
||||
Ok(McpEventCatalogSnapshot {
|
||||
cache_key,
|
||||
events: result.events,
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens an MCP event subscription with the supplied event arguments.
|
||||
pub async fn open_event_stream(
|
||||
&self,
|
||||
event_name: &str,
|
||||
arguments: &Value,
|
||||
request_meta: Option<&Map<String, Value>>,
|
||||
) -> Result<McpEventStream> {
|
||||
let mut params = json!({
|
||||
"name": event_name,
|
||||
"arguments": arguments,
|
||||
});
|
||||
if let Some(request_meta) = request_meta {
|
||||
params["_meta"] = Value::Object(request_meta.clone());
|
||||
}
|
||||
|
||||
let connections = self.runtime.latest_connections();
|
||||
let (managed, _) = connections
|
||||
.client_by_name(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.await?;
|
||||
let request = managed
|
||||
.client
|
||||
.send_event_stream_request(Some(params))
|
||||
.await
|
||||
.context("events/stream failed for hosted Plugin Runtime")?;
|
||||
|
||||
Ok(McpEventStream {
|
||||
request: Some(request),
|
||||
runtime_handle: Handle::current(),
|
||||
_connections: connections,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_from_rmcp(resource: rmcp::model::Resource) -> Result<Resource> {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use rmcp::model::ClientRequest;
|
||||
use rmcp::model::CustomNotification;
|
||||
use rmcp::model::JsonRpcMessage;
|
||||
use rmcp::model::RequestId;
|
||||
use rmcp::model::ServerNotification;
|
||||
use rmcp::service::RoleClient;
|
||||
use rmcp::service::RxJsonRpcMessage;
|
||||
use rmcp::service::TxJsonRpcMessage;
|
||||
use rmcp::transport::IntoTransport;
|
||||
use rmcp::transport::Transport;
|
||||
use tokio::sync::OwnedSemaphorePermit;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) const MAX_EVENT_NOTIFICATION_BYTES: usize = 1024 * 1024;
|
||||
const MAX_QUEUED_EVENT_NOTIFICATION_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EventNotificationSender {
|
||||
notifications: mpsc::UnboundedSender<QueuedEventNotification>,
|
||||
available_bytes: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
pub struct EventNotificationReceiver {
|
||||
notifications: mpsc::UnboundedReceiver<QueuedEventNotification>,
|
||||
available_bytes: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
struct QueuedEventNotification {
|
||||
notification: CustomNotification,
|
||||
_bytes: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
pub(crate) fn event_notification_channel() -> (EventNotificationSender, EventNotificationReceiver) {
|
||||
let (notifications_tx, notifications_rx) = mpsc::unbounded_channel();
|
||||
let available_bytes = Arc::new(Semaphore::new(MAX_QUEUED_EVENT_NOTIFICATION_BYTES));
|
||||
|
||||
(
|
||||
EventNotificationSender {
|
||||
notifications: notifications_tx,
|
||||
available_bytes: Arc::clone(&available_bytes),
|
||||
},
|
||||
EventNotificationReceiver {
|
||||
notifications: notifications_rx,
|
||||
available_bytes,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl EventNotificationSender {
|
||||
async fn send(
|
||||
&self,
|
||||
notification: CustomNotification,
|
||||
notification_bytes: usize,
|
||||
) -> Result<(), ()> {
|
||||
let bytes = u32::try_from(notification_bytes).map_err(|_| ())?;
|
||||
let permit = Arc::clone(&self.available_bytes)
|
||||
.acquire_many_owned(bytes)
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
self.notifications
|
||||
.send(QueuedEventNotification {
|
||||
notification,
|
||||
_bytes: permit,
|
||||
})
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
self.available_bytes.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventNotificationReceiver {
|
||||
pub async fn recv(&mut self) -> Option<CustomNotification> {
|
||||
self.notifications
|
||||
.recv()
|
||||
.await
|
||||
.map(|queued| queued.notification)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventNotificationReceiver {
|
||||
fn drop(&mut self) {
|
||||
self.available_bytes.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes Plugin Runtime event notifications before rmcp can log their payloads.
|
||||
pub(crate) fn capture_event_notifications<T, E, A>(
|
||||
transport: T,
|
||||
) -> impl Transport<RoleClient, Error = E> + 'static
|
||||
where
|
||||
T: IntoTransport<RoleClient, E, A>,
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
EventNotificationTransport {
|
||||
inner: transport.into_transport(),
|
||||
routes: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
struct EventNotificationTransport<T> {
|
||||
inner: T,
|
||||
routes: Arc<Mutex<HashMap<RequestId, EventNotificationSender>>>,
|
||||
}
|
||||
|
||||
impl<T> Transport<RoleClient> for EventNotificationTransport<T>
|
||||
where
|
||||
T: Transport<RoleClient> + 'static,
|
||||
{
|
||||
type Error = T::Error;
|
||||
|
||||
fn send(
|
||||
&mut self,
|
||||
mut message: TxJsonRpcMessage<RoleClient>,
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
||||
let request_route = match &mut message {
|
||||
JsonRpcMessage::Request(envelope) => match &mut envelope.request {
|
||||
ClientRequest::CustomRequest(request) if request.method == "events/stream" => {
|
||||
request
|
||||
.extensions
|
||||
.remove::<EventNotificationSender>()
|
||||
.map(|sender| (envelope.id.clone(), sender))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
JsonRpcMessage::Notification(envelope) => {
|
||||
if let rmcp::model::ClientNotification::CancelledNotification(cancelled) =
|
||||
&envelope.notification
|
||||
&& let Some(request_id) = cancelled.params.request_id.as_ref()
|
||||
&& let Some(route) = self
|
||||
.routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(request_id)
|
||||
{
|
||||
route.close();
|
||||
}
|
||||
None
|
||||
}
|
||||
JsonRpcMessage::Response(_) | JsonRpcMessage::Error(_) => None,
|
||||
};
|
||||
|
||||
let route_id = request_route
|
||||
.as_ref()
|
||||
.map(|(request_id, _)| request_id.clone());
|
||||
if let Some((request_id, sender)) = request_route
|
||||
&& let Some(previous) = self
|
||||
.routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(request_id, sender)
|
||||
{
|
||||
previous.close();
|
||||
}
|
||||
|
||||
let routes = Arc::clone(&self.routes);
|
||||
let send = self.inner.send(message);
|
||||
async move {
|
||||
let result = send.await;
|
||||
if result.is_err()
|
||||
&& let Some(route_id) = route_id
|
||||
&& let Some(route) = routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&route_id)
|
||||
{
|
||||
route.close();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive(&mut self) -> Option<RxJsonRpcMessage<RoleClient>> {
|
||||
loop {
|
||||
let message = self.inner.receive().await?;
|
||||
if let Some(request_id) = response_id(&message) {
|
||||
if let Some(route) = self
|
||||
.routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(request_id)
|
||||
{
|
||||
route.close();
|
||||
}
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
let JsonRpcMessage::Notification(envelope) = &message else {
|
||||
return Some(message);
|
||||
};
|
||||
let ServerNotification::CustomNotification(notification) = &envelope.notification
|
||||
else {
|
||||
return Some(message);
|
||||
};
|
||||
if !notification.method.starts_with("notifications/events/") {
|
||||
return Some(message);
|
||||
}
|
||||
let Some(subscription_id) =
|
||||
rmcp::model::GetMeta::get_meta(notification).subscription_id()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let route = self
|
||||
.routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(&subscription_id)
|
||||
.cloned();
|
||||
let Some(route) = route else {
|
||||
continue;
|
||||
};
|
||||
let notification_bytes = serde_json::to_vec(&message)
|
||||
.map(|message| message.len())
|
||||
.unwrap_or(usize::MAX);
|
||||
if notification_bytes > MAX_EVENT_NOTIFICATION_BYTES {
|
||||
warn!(
|
||||
notification_bytes,
|
||||
"discarding oversized MCP event notification"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if route
|
||||
.send(notification.clone(), notification_bytes)
|
||||
.await
|
||||
.is_err()
|
||||
&& let Some(route) = self
|
||||
.routes
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&subscription_id)
|
||||
{
|
||||
route.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), Self::Error> {
|
||||
let routes =
|
||||
std::mem::take(&mut *self.routes.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
for route in routes.into_values() {
|
||||
route.close();
|
||||
}
|
||||
self.inner.close().await
|
||||
}
|
||||
}
|
||||
|
||||
fn response_id(message: &RxJsonRpcMessage<RoleClient>) -> Option<&RequestId> {
|
||||
match message {
|
||||
JsonRpcMessage::Response(response) => Some(&response.id),
|
||||
JsonRpcMessage::Error(error) => error.id.as_ref(),
|
||||
JsonRpcMessage::Request(_) | JsonRpcMessage::Notification(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::PoisonError;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
@@ -41,6 +42,7 @@ use rmcp::model::ErrorCode;
|
||||
use rmcp::model::ErrorData;
|
||||
use rmcp::model::JsonRpcMessage;
|
||||
use rmcp::model::ProtocolVersion;
|
||||
use rmcp::model::RequestId;
|
||||
use rmcp::model::ServerJsonRpcMessage;
|
||||
use rmcp::model::ServerResult;
|
||||
use rmcp::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION;
|
||||
@@ -51,7 +53,9 @@ use rmcp::transport::streamable_http_client::StreamableHttpError;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpPostResponse;
|
||||
use sse_stream::Sse;
|
||||
use sse_stream::SseStream;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::event_notification_transport::MAX_EVENT_NOTIFICATION_BYTES;
|
||||
use crate::incoming_jsonrpc::deserialize_incoming_jsonrpc_message;
|
||||
use crate::incoming_jsonrpc::normalize_sse_jsonrpc_message;
|
||||
use crate::local_stdio_transport::MAX_MCP_STDIO_LINE_BYTES;
|
||||
@@ -65,6 +69,7 @@ const JSON_MIME_TYPE: &str = "application/json";
|
||||
const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
|
||||
const NON_JSON_RESPONSE_BODY_PREVIEW_BYTES: usize = 8_192;
|
||||
const LEGACY_HTTP_PREVALIDATION_ERROR_CODE: ErrorCode = ErrorCode(-32000);
|
||||
const EVENT_STREAM_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StreamableHttpRedirectMode {
|
||||
@@ -77,11 +82,26 @@ pub(crate) struct StreamableHttpClientAdapter {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
default_headers: HeaderMap,
|
||||
auth_provider: Option<SharedAuthProvider>,
|
||||
event_stream_cancellations: Arc<Mutex<HashMap<RequestId, oneshot::Sender<()>>>>,
|
||||
has_configured_headers: bool,
|
||||
redirect_mode: StreamableHttpRedirectMode,
|
||||
initialize_deadline: Arc<Mutex<Option<Instant>>>,
|
||||
}
|
||||
|
||||
struct EventStreamCancellation {
|
||||
request_id: RequestId,
|
||||
cancellations: Arc<Mutex<HashMap<RequestId, oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
impl Drop for EventStreamCancellation {
|
||||
fn drop(&mut self) {
|
||||
self.cancellations
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&self.request_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum StreamableHttpClientAdapterError {
|
||||
#[error("streamable HTTP session expired with 404 Not Found")]
|
||||
@@ -107,6 +127,7 @@ impl StreamableHttpClientAdapter {
|
||||
http_client,
|
||||
default_headers,
|
||||
auth_provider,
|
||||
event_stream_cancellations: Arc::default(),
|
||||
has_configured_headers,
|
||||
redirect_mode,
|
||||
initialize_deadline,
|
||||
@@ -163,13 +184,18 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
)?;
|
||||
}
|
||||
|
||||
let maximum_response_bytes = (mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE)
|
||||
|| headers
|
||||
.get(HEADER_MCP_PROTOCOL_VERSION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some(ProtocolVersion::V_2026_07_28.as_str()))
|
||||
.then_some(MAX_MCP_STDIO_LINE_BYTES);
|
||||
let redirect_policy = if mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE) {
|
||||
let is_discovery_request = mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE);
|
||||
let is_event_stream_request = mcp_method.as_deref() == Some("events/stream");
|
||||
let uses_modern_protocol = headers
|
||||
.get(HEADER_MCP_PROTOCOL_VERSION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some(ProtocolVersion::V_2026_07_28.as_str());
|
||||
let maximum_response_bytes = if is_event_stream_request {
|
||||
Some(MAX_EVENT_NOTIFICATION_BYTES)
|
||||
} else {
|
||||
(is_discovery_request || uses_modern_protocol).then_some(MAX_MCP_STDIO_LINE_BYTES)
|
||||
};
|
||||
let redirect_policy = if is_discovery_request {
|
||||
HttpRedirectPolicy::Stop
|
||||
} else {
|
||||
self.redirect_policy(&headers)
|
||||
@@ -197,19 +223,40 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
|
||||
let body = serde_json::to_vec(&message).map_err(StreamableHttpError::Deserialize)?;
|
||||
let has_authorization_header = headers.contains_key(AUTHORIZATION);
|
||||
let response = self
|
||||
.http_client
|
||||
.http_request_stream(HttpRequestParams {
|
||||
method: "POST".to_string(),
|
||||
url: uri.to_string(),
|
||||
headers: protocol_headers(&headers),
|
||||
body: Some(body.into()),
|
||||
timeout_ms,
|
||||
redirect_policy,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: true,
|
||||
})
|
||||
.await;
|
||||
if let JsonRpcMessage::Notification(notification) = &message
|
||||
&& let ClientNotification::CancelledNotification(cancelled) = ¬ification.notification
|
||||
&& let Some(request_id) = cancelled.params.request_id.as_ref()
|
||||
&& let Some(cancellation) = self
|
||||
.event_stream_cancellations
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(request_id)
|
||||
{
|
||||
let _ = cancellation.send(());
|
||||
return Ok(StreamableHttpPostResponse::Accepted);
|
||||
}
|
||||
|
||||
let request = self.http_client.http_request_stream(HttpRequestParams {
|
||||
method: "POST".to_string(),
|
||||
url: uri.to_string(),
|
||||
headers: protocol_headers(&headers),
|
||||
body: Some(body.into()),
|
||||
timeout_ms,
|
||||
redirect_policy,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: true,
|
||||
});
|
||||
let response = if is_event_stream_request {
|
||||
tokio::time::timeout(EVENT_STREAM_RESPONSE_TIMEOUT, request)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
StreamableHttpError::UnexpectedServerResponse(
|
||||
"timed out waiting for MCP event stream response headers".into(),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
request.await
|
||||
};
|
||||
let (response, mut body_stream) = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
@@ -309,7 +356,11 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
}
|
||||
match content_type.as_deref() {
|
||||
Some(content_type) if content_type.starts_with(EVENT_STREAM_MIME_TYPE) => {
|
||||
let mut event_stream = sse_stream_from_body(body_stream, maximum_response_bytes);
|
||||
let mut event_stream = sse_stream_from_body(
|
||||
body_stream,
|
||||
maximum_response_bytes,
|
||||
is_discovery_request || uses_modern_protocol,
|
||||
);
|
||||
if mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE) {
|
||||
while let Some(event) = event_stream.next().await {
|
||||
let event = event.map_err(StreamableHttpError::Sse)?;
|
||||
@@ -340,6 +391,35 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
"empty sse stream".into(),
|
||||
));
|
||||
}
|
||||
if is_event_stream_request && let JsonRpcMessage::Request(request) = &message {
|
||||
let (cancel, cancelled) = oneshot::channel();
|
||||
let cancellation = EventStreamCancellation {
|
||||
request_id: request.id.clone(),
|
||||
cancellations: Arc::clone(&self.event_stream_cancellations),
|
||||
};
|
||||
cancellation
|
||||
.cancellations
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(cancellation.request_id.clone(), cancel);
|
||||
|
||||
event_stream = stream::unfold(
|
||||
Some((event_stream, cancelled, cancellation)),
|
||||
|state| async move {
|
||||
let (mut event_stream, mut cancelled, cancellation) = state?;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
_ = &mut cancelled => None,
|
||||
event = event_stream.next() => event.map(|event| {
|
||||
(event, Some((event_stream, cancelled, cancellation)))
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
.boxed();
|
||||
}
|
||||
Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
|
||||
}
|
||||
Some(content_type) if content_type.starts_with(JSON_MIME_TYPE) => {
|
||||
@@ -507,12 +587,16 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
let maximum_response_bytes = headers
|
||||
let uses_modern_protocol = headers
|
||||
.get(HEADER_MCP_PROTOCOL_VERSION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|version| version == ProtocolVersion::V_2026_07_28.as_str())
|
||||
.then_some(MAX_MCP_STDIO_LINE_BYTES);
|
||||
Ok(sse_stream_from_body(body_stream, maximum_response_bytes))
|
||||
.is_some_and(|version| version == ProtocolVersion::V_2026_07_28.as_str());
|
||||
let maximum_response_bytes = uses_modern_protocol.then_some(MAX_MCP_STDIO_LINE_BYTES);
|
||||
Ok(sse_stream_from_body(
|
||||
body_stream,
|
||||
maximum_response_bytes,
|
||||
uses_modern_protocol,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,8 +947,8 @@ async fn collect_body(
|
||||
fn sse_stream_from_body(
|
||||
body_stream: HttpResponseBodyStream,
|
||||
maximum_event_bytes: Option<usize>,
|
||||
modern_session: bool,
|
||||
) -> BoxStream<'static, std::result::Result<Sse, sse_stream::Error>> {
|
||||
let modern_session = maximum_event_bytes.is_some();
|
||||
SseStream::from_bytes_stream(stream::unfold(
|
||||
(body_stream, SseEventSizeLimit::new(maximum_event_bytes)),
|
||||
|(mut body_stream, mut size_limit)| async move {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth_status;
|
||||
mod elicitation_client_service;
|
||||
mod event_notification_transport;
|
||||
mod executor_process_transport;
|
||||
mod http_client_adapter;
|
||||
mod in_process_transport;
|
||||
@@ -24,6 +25,7 @@ pub use auth_status::determine_streamable_http_auth_status;
|
||||
pub use auth_status::determine_streamable_http_auth_status_from_credentials;
|
||||
pub use auth_status::discover_streamable_http_oauth;
|
||||
pub use codex_protocol::protocol::McpAuthStatus;
|
||||
pub use event_notification_transport::EventNotificationReceiver;
|
||||
pub use http_client_adapter::StreamableHttpRedirectMode;
|
||||
pub use in_process_transport::InProcessTransportFactory;
|
||||
pub use oauth::StoredOAuthCredentialSnapshot;
|
||||
@@ -40,6 +42,7 @@ pub use perform_oauth_login::perform_oauth_login_return_url;
|
||||
pub use perform_oauth_login::perform_oauth_login_silent;
|
||||
pub use protocol_mode::McpProtocolMode;
|
||||
pub use rmcp::model::ElicitationAction;
|
||||
pub use rmcp_client::CancellableEventStreamRequest;
|
||||
pub use rmcp_client::Elicitation;
|
||||
pub use rmcp_client::ElicitationResponse;
|
||||
pub use rmcp_client::ListToolsWithConnectorIdResult;
|
||||
|
||||
@@ -50,6 +50,7 @@ use rmcp::model::ServerResult;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::service::ClientCacheConfig;
|
||||
use rmcp::service::ClientServiceExt;
|
||||
use rmcp::service::RequestHandle;
|
||||
use rmcp::service::RoleClient;
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::transport::AuthorizationManager;
|
||||
@@ -70,6 +71,8 @@ use tracing::instrument;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::elicitation_client_service::ElicitationClientService;
|
||||
use crate::event_notification_transport::capture_event_notifications;
|
||||
use crate::event_notification_transport::event_notification_channel;
|
||||
use crate::http_client_adapter::StreamableHttpClientAdapter;
|
||||
use crate::http_client_adapter::StreamableHttpClientAdapterError;
|
||||
use crate::http_client_adapter::StreamableHttpRedirectMode;
|
||||
@@ -344,6 +347,12 @@ pub struct ListToolsWithConnectorIdResult {
|
||||
pub tools: Vec<ToolWithConnectorId>,
|
||||
}
|
||||
|
||||
/// An active Plugin Runtime event request and its request-scoped notifications.
|
||||
pub struct CancellableEventStreamRequest {
|
||||
pub handle: RequestHandle<RoleClient>,
|
||||
pub notifications: crate::EventNotificationReceiver,
|
||||
}
|
||||
|
||||
/// MCP client implemented on top of the official `rmcp` SDK.
|
||||
/// https://github.com/modelcontextprotocol/rust-sdk
|
||||
pub struct RmcpClient {
|
||||
@@ -845,10 +854,20 @@ impl RmcpClient {
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<ServerResult> {
|
||||
self.send_custom_request_with_timeout(method, params, /*timeout*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_custom_request_with_timeout(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ServerResult> {
|
||||
self.refresh_oauth_if_needed().await?;
|
||||
let response = self
|
||||
.run_service_operation("requests/custom", /*timeout*/ None, move |service| {
|
||||
.run_service_operation("requests/custom", timeout, move |service| {
|
||||
let params = params.clone();
|
||||
async move {
|
||||
service
|
||||
@@ -864,6 +883,29 @@ impl RmcpClient {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Starts a Plugin Runtime event stream without waiting for its final response.
|
||||
pub async fn send_event_stream_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<CancellableEventStreamRequest> {
|
||||
let service = self.service().await?;
|
||||
let (sender, notifications) = event_notification_channel();
|
||||
let mut request = CustomRequest::new("events/stream", params);
|
||||
request.extensions.insert(sender);
|
||||
let handle = service
|
||||
.peer()
|
||||
.send_cancellable_request(
|
||||
ClientRequest::CustomRequest(request),
|
||||
rmcp::service::PeerRequestOptions::no_options(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(CancellableEventStreamRequest {
|
||||
handle,
|
||||
notifications,
|
||||
})
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<RunningService<RoleClient, ElicitationClientService>>> {
|
||||
let guard = self.state.lock().await;
|
||||
match &*guard {
|
||||
@@ -1139,7 +1181,7 @@ impl RmcpClient {
|
||||
),
|
||||
PendingTransport::StreamableHttp { transport } => (
|
||||
client_service
|
||||
.serve_with_lifecycle(transport, lifecycle)
|
||||
.serve_with_lifecycle(capture_event_notifications(transport), lifecycle)
|
||||
.boxed(),
|
||||
None,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
mod streamable_http_test_support;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::sse::Event;
|
||||
use axum::response::sse::Sse;
|
||||
use axum::routing::post;
|
||||
use futures::StreamExt as _;
|
||||
use futures::stream;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use streamable_http_test_support::create_client;
|
||||
|
||||
struct StreamClosed {
|
||||
event_name: String,
|
||||
closed: mpsc::UnboundedSender<String>,
|
||||
}
|
||||
|
||||
impl Drop for StreamClosed {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.closed.send(self.event_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_response(message: &Value) -> axum::response::Response {
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "plugin-runtime", "version": "1.0.0"},
|
||||
},
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn plugin_runtime_notifications(event_name: &str, request_id: Value) -> [Value; 2] {
|
||||
let metadata = json!({"io.modelcontextprotocol/subscriptionId": request_id});
|
||||
[
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/events/active",
|
||||
"params": {"cursor": null, "truncated": false, "_meta": metadata},
|
||||
}),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/events/event",
|
||||
"params": {
|
||||
"eventId": format!("event-{event_name}"),
|
||||
"name": event_name,
|
||||
"timestamp": "2026-08-07T12:00:00Z",
|
||||
"data": {"issue": 42},
|
||||
"cursor": null,
|
||||
"_meta": metadata,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_runtime_event_streams_are_isolated_and_cancel_locally() -> anyhow::Result<()> {
|
||||
let (stream_closed_tx, mut stream_closed_rx) = mpsc::unbounded_channel::<String>();
|
||||
let router = Router::new().route(
|
||||
"/mcp",
|
||||
post(move |Json(message): Json<Value>| {
|
||||
let stream_closed_tx = stream_closed_tx.clone();
|
||||
async move {
|
||||
match message["method"].as_str() {
|
||||
Some("initialize") => initialize_response(&message),
|
||||
Some("notifications/initialized") => StatusCode::ACCEPTED.into_response(),
|
||||
Some("events/stream") => {
|
||||
let event_name = message["params"]["name"]
|
||||
.as_str()
|
||||
.expect("event stream request must contain a name")
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
message.pointer("/params/arguments"),
|
||||
Some(&json!({"project": "codex"}))
|
||||
);
|
||||
assert!(message["params"].get("cursor").is_none());
|
||||
|
||||
let closed = StreamClosed {
|
||||
event_name: event_name.clone(),
|
||||
closed: stream_closed_tx,
|
||||
};
|
||||
let events = stream::iter(
|
||||
plugin_runtime_notifications(&event_name, message["id"].clone())
|
||||
.into_iter()
|
||||
.map(|notification| {
|
||||
Ok::<_, Infallible>(
|
||||
Event::default()
|
||||
.event("message")
|
||||
.data(notification.to_string()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.chain(stream::pending())
|
||||
.map(move |event| {
|
||||
let _ = &closed;
|
||||
event
|
||||
});
|
||||
|
||||
Sse::new(events).into_response()
|
||||
}
|
||||
Some("notifications/cancelled") => {
|
||||
panic!("event cancellation should close the local stream without a POST")
|
||||
}
|
||||
method => panic!("unexpected Plugin Runtime request: {method:?}"),
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base_url = format!("http://{}", listener.local_addr()?);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, router).await });
|
||||
let client = create_client(&base_url).await?;
|
||||
|
||||
let mut first = client
|
||||
.send_event_stream_request(Some(json!({
|
||||
"name": "github.pull_request.opened",
|
||||
"arguments": {"project": "codex"},
|
||||
})))
|
||||
.await?;
|
||||
let mut second = client
|
||||
.send_event_stream_request(Some(json!({
|
||||
"name": "gmail.message.received",
|
||||
"arguments": {"project": "codex"},
|
||||
})))
|
||||
.await?;
|
||||
|
||||
for (event_name, request) in [
|
||||
("github.pull_request.opened", &mut first),
|
||||
("gmail.message.received", &mut second),
|
||||
] {
|
||||
let active = timeout(Duration::from_secs(5), request.notifications.recv())
|
||||
.await?
|
||||
.context("event stream closed before activation")?;
|
||||
assert_eq!(active.method, "notifications/events/active");
|
||||
assert_eq!(
|
||||
active.params,
|
||||
Some(json!({"cursor": null, "truncated": false}))
|
||||
);
|
||||
|
||||
let event = timeout(Duration::from_secs(5), request.notifications.recv())
|
||||
.await?
|
||||
.context("event stream closed before delivery")?;
|
||||
assert_eq!(event.method, "notifications/events/event");
|
||||
assert_eq!(
|
||||
event.params.as_ref().and_then(|params| params.get("name")),
|
||||
Some(&json!(event_name))
|
||||
);
|
||||
}
|
||||
|
||||
first
|
||||
.handle
|
||||
.cancel(Some("event subscription closed".to_string()))
|
||||
.await?;
|
||||
let closed = timeout(Duration::from_secs(5), stream_closed_rx.recv())
|
||||
.await?
|
||||
.context("cancelled stream did not close")?;
|
||||
assert_eq!(closed, "github.pull_request.opened");
|
||||
|
||||
second
|
||||
.handle
|
||||
.cancel(Some("event subscription closed".to_string()))
|
||||
.await?;
|
||||
let closed = timeout(Duration::from_secs(5), stream_closed_rx.recv())
|
||||
.await?
|
||||
.context("cancelled stream did not close")?;
|
||||
assert_eq!(closed, "gmail.message.received");
|
||||
|
||||
client.shutdown().await;
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_runtime_event_stream_times_out_before_stalled_headers() -> anyhow::Result<()> {
|
||||
let request_started = std::sync::Arc::new(Notify::new());
|
||||
let server_request_started = std::sync::Arc::clone(&request_started);
|
||||
let router = Router::new().route(
|
||||
"/mcp",
|
||||
post(move |Json(message): Json<Value>| {
|
||||
let request_started = std::sync::Arc::clone(&server_request_started);
|
||||
async move {
|
||||
match message["method"].as_str() {
|
||||
Some("initialize") => initialize_response(&message),
|
||||
Some("notifications/initialized") => StatusCode::ACCEPTED.into_response(),
|
||||
Some("events/stream") => {
|
||||
request_started.notify_one();
|
||||
std::future::pending().await
|
||||
}
|
||||
method => panic!("unexpected Plugin Runtime request: {method:?}"),
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base_url = format!("http://{}", listener.local_addr()?);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, router).await });
|
||||
let client = create_client(&base_url).await?;
|
||||
let request = client
|
||||
.send_event_stream_request(Some(json!({
|
||||
"name": "github.pull_request.opened",
|
||||
"arguments": {},
|
||||
})))
|
||||
.await?;
|
||||
|
||||
timeout(Duration::from_secs(5), request_started.notified())
|
||||
.await
|
||||
.context("event stream request did not reach the server")?;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
let error = request
|
||||
.handle
|
||||
.rx
|
||||
.await?
|
||||
.expect_err("stalled response headers must time out");
|
||||
tokio::time::resume();
|
||||
assert!(error.to_string().contains("timed out"));
|
||||
|
||||
client.shutdown().await;
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user