mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
### Why Remote streamable HTTP MCP needs the executor to perform ordinary HTTP requests on the executor side. This keeps network placement aligned with `experimental_environment = "remote"` without adding MCP-specific executor APIs. ### What - Add an executor-side `http/request` runner backed by `reqwest`. - Validate request method and URL scheme, preserving the transport boundary at plain HTTP. - Return buffered responses for ordinary calls and emit ordered `http/request/bodyDelta` notifications for streaming responses. - Register the request handler in the exec-server router. - Document the runner entrypoint, conversion helpers, body-stream bridge, notification sender, timeout behavior, and new integration-test helpers. - Add exec-server integration tests with the existing websocket harness and a local TCP HTTP peer for buffered and streamed responses, with comments spelling out what each test proves and its setup/exercise/assert phases. ### Stack 1. #18581 protocol 2. #18582 runner 3. #18583 RMCP client 4. #18584 manager wiring and local/remote coverage ### Verification - `just fmt` - `cargo check -p codex-exec-server -p codex-rmcp-client --tests` - `cargo check -p codex-core --test all` compile-only - `git diff --check` - Online full CI is running from the `full-ci` branch, including the remote Rust test job. Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1120 lines
38 KiB
Rust
1120 lines
38 KiB
Rust
use std::future::Future;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::bail;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use codex_app_server_protocol::JSONRPCNotification;
|
|
use codex_app_server_protocol::JSONRPCRequest;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_exec_server::ExecServerClient;
|
|
use codex_exec_server::HttpHeader;
|
|
use codex_exec_server::HttpRequestBodyDeltaNotification;
|
|
use codex_exec_server::HttpRequestParams;
|
|
use codex_exec_server::HttpRequestResponse;
|
|
use codex_exec_server::InitializeParams;
|
|
use codex_exec_server::InitializeResponse;
|
|
use codex_exec_server::RemoteExecServerConnectArgs;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use serde_json::from_slice;
|
|
use serde_json::from_str;
|
|
use serde_json::from_value;
|
|
use serde_json::to_string;
|
|
use serde_json::to_value;
|
|
use tokio::net::TcpListener;
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::oneshot;
|
|
use tokio::task::JoinHandle;
|
|
use tokio::time::timeout;
|
|
use tokio_tungstenite::WebSocketStream;
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
const CLIENT_NAME: &str = "test-exec-server-client";
|
|
const HTTP_REQUEST_METHOD: &str = "http/request";
|
|
const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta";
|
|
const INITIALIZE_METHOD: &str = "initialize";
|
|
const INITIALIZED_METHOD: &str = "initialized";
|
|
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const HTTP_BODY_DELTA_CHANNEL_CAPACITY: u64 = 256;
|
|
const OVERFLOWING_BODY_DELTA_FRAMES: u64 = 1_024;
|
|
|
|
/// What this tests: the buffered HTTP helper always sends a buffered
|
|
/// `http/request`, even when a caller accidentally provides streaming flags.
|
|
#[tokio::test]
|
|
async fn http_request_forces_buffered_request_params() -> Result<()> {
|
|
// Phase 1: start a fake WebSocket exec-server so the test covers the
|
|
// public client connection path without depending on the HTTP runner.
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
// Phase 2: verify the buffered helper forces buffered mode before it
|
|
// sends the JSON-RPC call.
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/buffered".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "ignored-stream-id".to_string(),
|
|
stream_response: false,
|
|
}
|
|
);
|
|
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: b"buffered".to_vec().into(),
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 3: call the buffered helper with streaming-only fields populated
|
|
// and assert callers still receive the buffered response body.
|
|
let response = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/buffered".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "ignored-stream-id".to_string(),
|
|
stream_response: true,
|
|
}),
|
|
)
|
|
.await
|
|
.context("buffered http/request should complete")??;
|
|
assert_eq!(
|
|
response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: b"buffered".to_vec().into(),
|
|
}
|
|
);
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: streamed executor HTTP response frames are routed by the
|
|
/// client's generated request id, delivered in sequence, and concatenated by
|
|
/// the caller.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_deltas() -> Result<()> {
|
|
// Phase 1: script two requests. The caller supplies reusable ids, but the
|
|
// client replaces them with connection-local ids on the wire.
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp".to_string(),
|
|
headers: vec![HttpHeader {
|
|
name: "accept".to_string(),
|
|
value: "text/event-stream".to_string(),
|
|
}],
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
|
|
// Phase 2: return headers first, then body notifications in the order
|
|
// the public body stream should expose them.
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: vec![HttpHeader {
|
|
name: "content-type".to_string(),
|
|
value: "text/event-stream".to_string(),
|
|
}],
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
for delta in [
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 1,
|
|
delta: b"hello ".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
},
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 2,
|
|
delta: b"world".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
},
|
|
HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 3,
|
|
delta: b"!".to_vec().into(),
|
|
done: true,
|
|
error: None,
|
|
},
|
|
] {
|
|
peer.write_body_delta(delta).await?;
|
|
}
|
|
|
|
// Phase 3: accept the next generated request id after EOF.
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/reuse".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-2".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 204,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 4: start a streaming HTTP request through the public client API.
|
|
let (response, mut body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp".to_string(),
|
|
headers: vec![HttpHeader {
|
|
name: "accept".to_string(),
|
|
value: "text/event-stream".to_string(),
|
|
}],
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: vec![HttpHeader {
|
|
name: "content-type".to_string(),
|
|
value: "text/event-stream".to_string(),
|
|
}],
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
|
|
// Phase 5: drain the body stream and verify the caller-visible byte order.
|
|
let mut body = Vec::new();
|
|
while let Some(chunk) = timeout(TEST_TIMEOUT, body_stream.recv())
|
|
.await
|
|
.context("http response body delta should arrive")??
|
|
{
|
|
body.extend_from_slice(&chunk);
|
|
}
|
|
assert_eq!(body, b"hello world!".to_vec());
|
|
|
|
// Phase 6: start another stream through the public API to validate cleanup
|
|
// after EOF without reaching into the client routing table.
|
|
let (reuse_response, _reuse_body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/reuse".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("second streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
reuse_response,
|
|
HttpRequestResponse {
|
|
status: 204,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: dropping a body stream with a queued terminal frame removes
|
|
/// the old route while the next stream gets a fresh generated id.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_drops_queued_terminal_before_next_generated_id() -> Result<()> {
|
|
// Phase 1: send terminal EOF before the header response so the public body
|
|
// stream starts with EOF already queued but unread.
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/queued-terminal".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 1,
|
|
delta: Vec::new().into(),
|
|
done: true,
|
|
error: None,
|
|
})
|
|
.await?;
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
// Phase 2: accept another stream after the client drops the unread
|
|
// body. The second request receives a distinct generated id.
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-queued-terminal".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-2".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 204,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 3: drop the body stream without reading the queued EOF frame.
|
|
let (response, body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/queued-terminal".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
drop(body_stream);
|
|
|
|
// Phase 4: start another stream through the public API. The caller-provided
|
|
// id is ignored, so the request uses the next generated route id.
|
|
let params = HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-queued-terminal".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
};
|
|
let (reuse_response, _reuse_body_stream) =
|
|
timeout(TEST_TIMEOUT, client.http_request_stream(params))
|
|
.await
|
|
.context("second streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
reuse_response,
|
|
HttpRequestResponse {
|
|
status: 204,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: cancelling a streaming HTTP request while it is waiting for
|
|
/// headers drops its route, and a later stream gets a fresh generated id.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request() -> Result<()> {
|
|
// Phase 1: coordinate cancellation after the fake server observes the
|
|
// first request but before it returns headers. The server later sends a
|
|
// stale delta for the cancelled id before serving the fresh stream.
|
|
let (request_seen_tx, request_seen_rx) = oneshot::channel();
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (_request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/cancel".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
request_seen_tx
|
|
.send(())
|
|
.expect("test should wait for the first request");
|
|
|
|
// Phase 2: the next stream uses a new generated id. A late body delta
|
|
// for the cancelled id is ignored by the client-side router.
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-cancelled".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-2".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 1,
|
|
delta: b"stale".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
})
|
|
.await?;
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-2".to_string(),
|
|
seq: 1,
|
|
delta: b"fresh".to_vec().into(),
|
|
done: true,
|
|
error: None,
|
|
})
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 3: start a streaming request and abort the caller future while it
|
|
// is blocked waiting for response headers.
|
|
let client_for_request = client.clone();
|
|
let stream_task = tokio::spawn(async move {
|
|
let _ = client_for_request
|
|
.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/cancel".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
})
|
|
.await;
|
|
});
|
|
request_seen_rx
|
|
.await
|
|
.expect("server should observe the first http/request");
|
|
stream_task.abort();
|
|
let _ = stream_task.await;
|
|
|
|
// Phase 4: start a new stream immediately. It receives only the fresh body
|
|
// bytes for its generated id.
|
|
let (response, mut body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-cancelled".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("second streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
let mut body = Vec::new();
|
|
while let Some(chunk) = timeout(TEST_TIMEOUT, body_stream.recv())
|
|
.await
|
|
.context("fresh http response body delta should arrive")??
|
|
{
|
|
body.extend_from_slice(&chunk);
|
|
}
|
|
assert_eq!(body, b"fresh".to_vec());
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: dropping a returned body stream before EOF removes its
|
|
/// route and prevents stale body deltas from reaching the next stream.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<()> {
|
|
// Phase 1: script two requests. The first returns only headers; after the
|
|
// client drops its body receiver, the server sends a stale body delta.
|
|
let (body_dropped_tx, body_dropped_rx) = oneshot::channel();
|
|
let (stale_delta_sent_tx, stale_delta_sent_rx) = oneshot::channel();
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/drop".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
body_dropped_rx
|
|
.await
|
|
.expect("test should drop the first body stream");
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq: 1,
|
|
delta: b"stale".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
})
|
|
.await?;
|
|
stale_delta_sent_tx
|
|
.send(())
|
|
.expect("test should wait for the stale delta");
|
|
|
|
// Phase 2: accept the next request with a new generated id. The new
|
|
// stream must receive only fresh body bytes.
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-dropped".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-2".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-2".to_string(),
|
|
seq: 1,
|
|
delta: b"fresh".to_vec().into(),
|
|
done: true,
|
|
error: None,
|
|
})
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 3: receive headers for the first stream, then drop the body stream
|
|
// without reading any body frames.
|
|
let (response, body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/drop".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
drop(body_stream);
|
|
body_dropped_tx
|
|
.send(())
|
|
.expect("server should wait for the body stream drop");
|
|
stale_delta_sent_rx
|
|
.await
|
|
.expect("server should send one stale nonterminal delta");
|
|
|
|
// Phase 4: start the next stream immediately. The caller-provided id is
|
|
// ignored, and the fresh generated id isolates it from stale bytes.
|
|
let (reuse_response, mut reuse_body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/retry-dropped".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("second streamed http/request should return headers")??;
|
|
assert_eq!(
|
|
reuse_response,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
}
|
|
);
|
|
let mut body = Vec::new();
|
|
while let Some(chunk) = timeout(TEST_TIMEOUT, reuse_body_stream.recv())
|
|
.await
|
|
.context("fresh http response body delta should arrive")??
|
|
{
|
|
body.extend_from_slice(&chunk);
|
|
}
|
|
assert_eq!(body, b"fresh".to_vec());
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: an in-flight streamed HTTP body is failed when the shared
|
|
/// JSON-RPC transport disconnects before a terminal body frame.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_fails_when_transport_disconnects() -> Result<()> {
|
|
// Phase 1: return response headers for a streaming request, then drop the
|
|
// fake server transport without sending EOF.
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/disconnect".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 2: start a streaming HTTP request and receive headers.
|
|
let (_response, mut body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/disconnect".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
|
|
// Phase 3: assert transport disconnect wakes the body stream with a
|
|
// terminal error instead of hanging.
|
|
let error = timeout(TEST_TIMEOUT, body_stream.recv())
|
|
.await
|
|
.context("disconnect should wake http body stream")?
|
|
.expect_err("disconnect should fail the http body stream");
|
|
let error_message = error.to_string();
|
|
assert_eq!(
|
|
error_message.starts_with(
|
|
"exec-server protocol error: http response stream `http-1` failed: exec-server transport disconnected"
|
|
),
|
|
true
|
|
);
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: transport disconnect still records a terminal stream
|
|
/// failure even when the client-side body-delta queue is already full.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_reports_disconnect_when_queue_is_full() -> Result<()> {
|
|
// Phase 1: fill the queued body-delta route exactly to capacity before the
|
|
// response headers arrive, then drop the transport without sending EOF.
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/disconnect-full-queue".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
for seq in 1..=HTTP_BODY_DELTA_CHANNEL_CAPACITY {
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq,
|
|
delta: b"x".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
})
|
|
.await?;
|
|
}
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 2: start the streaming request and receive headers while the
|
|
// queue is already full.
|
|
let (_response, mut body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/disconnect-full-queue".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
|
|
// Phase 3: drain the queued chunks and assert the transport disconnect is
|
|
// still reported as an error rather than a clean EOF.
|
|
let mut chunks = 0;
|
|
let error = loop {
|
|
match timeout(TEST_TIMEOUT, body_stream.recv())
|
|
.await
|
|
.context("disconnect should wake the full queued body stream")?
|
|
{
|
|
Ok(Some(_chunk)) => {
|
|
chunks += 1;
|
|
}
|
|
Ok(None) => bail!("disconnect with a full queue should not look like clean EOF"),
|
|
Err(error) => break error,
|
|
}
|
|
};
|
|
assert_eq!(
|
|
(
|
|
chunks,
|
|
error
|
|
.to_string()
|
|
.starts_with(
|
|
"exec-server protocol error: http response stream `http-1` failed: exec-server transport disconnected",
|
|
),
|
|
),
|
|
(HTTP_BODY_DELTA_CHANNEL_CAPACITY as usize, true)
|
|
);
|
|
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What this tests: body-delta backpressure closes the public body stream as
|
|
/// an error rather than letting callers accept a truncated body as clean EOF.
|
|
#[tokio::test]
|
|
async fn http_response_body_stream_reports_backpressure_truncation() -> Result<()> {
|
|
// Phase 1: send enough body frames before headers to overflow the bounded
|
|
// client-side route while the public request future is still pending.
|
|
let (finish_tx, finish_rx) = oneshot::channel();
|
|
let server = spawn_scripted_exec_server(|mut peer| async move {
|
|
let (request_id, params) = peer.read_http_request().await?;
|
|
assert_eq!(
|
|
params,
|
|
HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/backpressure".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "http-1".to_string(),
|
|
stream_response: true,
|
|
}
|
|
);
|
|
for seq in 1..=OVERFLOWING_BODY_DELTA_FRAMES {
|
|
peer.write_body_delta(HttpRequestBodyDeltaNotification {
|
|
request_id: "http-1".to_string(),
|
|
seq,
|
|
delta: b"x".to_vec().into(),
|
|
done: false,
|
|
error: None,
|
|
})
|
|
.await?;
|
|
}
|
|
peer.write_response(
|
|
request_id,
|
|
HttpRequestResponse {
|
|
status: 200,
|
|
headers: Vec::new(),
|
|
body: Vec::new().into(),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
// Phase 2: keep the transport connected so the body stream reports the
|
|
// backpressure failure rather than a disconnect.
|
|
finish_rx.await.expect("test should finish server task");
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
let client = server.connect_client().await?;
|
|
|
|
// Phase 3: start the streaming request; the server overfills the route
|
|
// before returning the body stream to this consumer.
|
|
let (_response, mut body_stream) = timeout(
|
|
TEST_TIMEOUT,
|
|
client.http_request_stream(HttpRequestParams {
|
|
method: "GET".to_string(),
|
|
url: "https://example.test/mcp/backpressure".to_string(),
|
|
headers: Vec::new(),
|
|
body: None,
|
|
timeout_ms: None,
|
|
request_id: "caller-stream-id".to_string(),
|
|
stream_response: false,
|
|
}),
|
|
)
|
|
.await
|
|
.context("streamed http/request should return headers")??;
|
|
|
|
// Phase 4: drain queued chunks and assert the truncated stream ends in an
|
|
// explicit error, not a clean EOF.
|
|
let mut chunks = 0;
|
|
let error = loop {
|
|
match timeout(TEST_TIMEOUT, body_stream.recv())
|
|
.await
|
|
.context("backpressure should close http body stream")?
|
|
{
|
|
Ok(Some(_chunk)) => {
|
|
chunks += 1;
|
|
}
|
|
Ok(None) => bail!("backpressure truncation should not look like clean EOF"),
|
|
Err(error) => break error,
|
|
}
|
|
};
|
|
assert_eq!(
|
|
(
|
|
chunks < OVERFLOWING_BODY_DELTA_FRAMES as usize,
|
|
error.to_string(),
|
|
),
|
|
(
|
|
true,
|
|
"exec-server protocol error: http response stream `http-1` failed: body delta channel filled before delivery".to_string(),
|
|
)
|
|
);
|
|
|
|
finish_tx
|
|
.send(())
|
|
.expect("server task should wait for test completion");
|
|
drop(client);
|
|
server.finish().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fake WebSocket exec-server used by the integration tests.
|
|
///
|
|
/// The helper exercises `ExecServerClient::connect_websocket`, including the
|
|
/// initialize handshake, while each test controls the exact JSON-RPC traffic
|
|
/// that follows.
|
|
struct ScriptedExecServer {
|
|
websocket_url: String,
|
|
task: JoinHandle<Result<()>>,
|
|
}
|
|
|
|
impl ScriptedExecServer {
|
|
/// Connects the public exec-server client to this fake WebSocket endpoint.
|
|
async fn connect_client(&self) -> Result<ExecServerClient> {
|
|
ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new(
|
|
self.websocket_url.clone(),
|
|
CLIENT_NAME.to_string(),
|
|
))
|
|
.await
|
|
.context("client should connect to fake exec-server")
|
|
}
|
|
|
|
/// Waits for the scripted fake server to finish.
|
|
async fn finish(self) -> Result<()> {
|
|
self.task
|
|
.await
|
|
.context("fake exec-server task should join")??;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Starts a fake exec-server that accepts one WebSocket client.
|
|
async fn spawn_scripted_exec_server<F, Fut>(script: F) -> Result<ScriptedExecServer>
|
|
where
|
|
F: FnOnce(JsonRpcPeer) -> Fut + Send + 'static,
|
|
Fut: Future<Output = Result<()>> + Send + 'static,
|
|
{
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.context("fake exec-server should bind")?;
|
|
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
|
let task = tokio::spawn(async move {
|
|
let (stream, _) = timeout(TEST_TIMEOUT, listener.accept())
|
|
.await
|
|
.context("fake exec-server should accept a client")??;
|
|
let websocket = accept_async(stream)
|
|
.await
|
|
.context("fake exec-server websocket handshake should complete")?;
|
|
let mut peer = JsonRpcPeer { websocket };
|
|
peer.complete_initialize().await?;
|
|
script(peer).await
|
|
});
|
|
Ok(ScriptedExecServer {
|
|
websocket_url,
|
|
task,
|
|
})
|
|
}
|
|
|
|
/// JSON-RPC peer for the fake exec-server WebSocket.
|
|
struct JsonRpcPeer {
|
|
websocket: WebSocketStream<TcpStream>,
|
|
}
|
|
|
|
impl JsonRpcPeer {
|
|
/// Completes and validates the client initialize handshake.
|
|
async fn complete_initialize(&mut self) -> Result<()> {
|
|
let request = self.read_request(INITIALIZE_METHOD).await?;
|
|
let params: InitializeParams = decode_request_params(&request)?;
|
|
assert_eq!(
|
|
params,
|
|
InitializeParams {
|
|
client_name: CLIENT_NAME.to_string(),
|
|
resume_session_id: None,
|
|
}
|
|
);
|
|
self.write_response(
|
|
request.id,
|
|
InitializeResponse {
|
|
session_id: "session-1".to_string(),
|
|
},
|
|
)
|
|
.await?;
|
|
self.read_notification(INITIALIZED_METHOD).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads one typed `http/request` call from the client.
|
|
async fn read_http_request(&mut self) -> Result<(RequestId, HttpRequestParams)> {
|
|
let request = self.read_request(HTTP_REQUEST_METHOD).await?;
|
|
let params = decode_request_params(&request)?;
|
|
Ok((request.id, params))
|
|
}
|
|
|
|
/// Reads a JSON-RPC request and validates its method.
|
|
async fn read_request(&mut self, expected_method: &str) -> Result<JSONRPCRequest> {
|
|
let message = self.read_message().await?;
|
|
let JSONRPCMessage::Request(request) = message else {
|
|
bail!("expected JSON-RPC request `{expected_method}`, got {message:?}");
|
|
};
|
|
if request.method != expected_method {
|
|
bail!(
|
|
"expected JSON-RPC request `{expected_method}`, got `{}`",
|
|
request.method
|
|
);
|
|
}
|
|
Ok(request)
|
|
}
|
|
|
|
/// Reads a JSON-RPC notification and validates its method.
|
|
async fn read_notification(&mut self, expected_method: &str) -> Result<JSONRPCNotification> {
|
|
let message = self.read_message().await?;
|
|
let JSONRPCMessage::Notification(notification) = message else {
|
|
bail!("expected JSON-RPC notification `{expected_method}`, got {message:?}");
|
|
};
|
|
if notification.method != expected_method {
|
|
bail!(
|
|
"expected JSON-RPC notification `{expected_method}`, got `{}`",
|
|
notification.method
|
|
);
|
|
}
|
|
Ok(notification)
|
|
}
|
|
|
|
/// Sends a successful JSON-RPC response.
|
|
async fn write_response<T>(&mut self, id: RequestId, result: T) -> Result<()>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
self.write_message(JSONRPCMessage::Response(JSONRPCResponse {
|
|
id,
|
|
result: to_value(result)?,
|
|
}))
|
|
.await
|
|
}
|
|
|
|
/// Sends one streamed HTTP body notification.
|
|
async fn write_body_delta(&mut self, delta: HttpRequestBodyDeltaNotification) -> Result<()> {
|
|
self.write_message(JSONRPCMessage::Notification(JSONRPCNotification {
|
|
method: HTTP_REQUEST_BODY_DELTA_METHOD.to_string(),
|
|
params: Some(to_value(delta)?),
|
|
}))
|
|
.await
|
|
}
|
|
|
|
/// Reads one WebSocket JSON-RPC message.
|
|
async fn read_message(&mut self) -> Result<JSONRPCMessage> {
|
|
let message = timeout(TEST_TIMEOUT, self.websocket.next())
|
|
.await
|
|
.context("timed out waiting for JSON-RPC message")?
|
|
.context("client websocket closed before JSON-RPC message arrived")?
|
|
.context("failed to read websocket message")?;
|
|
match message {
|
|
Message::Text(text) => from_str(text.as_ref()).context("text JSON-RPC"),
|
|
Message::Binary(bytes) => from_slice(bytes.as_ref()).context("binary JSON-RPC"),
|
|
Message::Close(frame) => bail!("client websocket closed: {frame:?}"),
|
|
other => bail!("expected text or binary JSON-RPC message, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Writes one WebSocket JSON-RPC message.
|
|
async fn write_message(&mut self, message: JSONRPCMessage) -> Result<()> {
|
|
let encoded = to_string(&message)?;
|
|
timeout(
|
|
TEST_TIMEOUT,
|
|
self.websocket.send(Message::Text(encoded.into())),
|
|
)
|
|
.await
|
|
.context("timed out writing JSON-RPC message")?
|
|
.context("failed to write JSON-RPC message")
|
|
}
|
|
}
|
|
|
|
/// Decodes a request params object into its typed protocol payload.
|
|
fn decode_request_params<T>(request: &JSONRPCRequest) -> Result<T>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let params = request
|
|
.params
|
|
.clone()
|
|
.context("JSON-RPC request should include params")?;
|
|
from_value(params).context("JSON-RPC request params should decode")
|
|
}
|