返回 CodeWhale
sse.rs
根目录 / crates / tui / src / mcp / sse.rs
1 use std::time::Duration;
2
3 use anyhow::{Context, Result};
4
5 use super::headers::{apply_safe_custom_headers, with_default_mcp_http_headers};
6 use super::http_client::McpHttpClient;
7 use super::wire::{
8 MAX_SSE_FRAME_BYTES, find_sse_event_separator_bytes, is_mcp_stale_session_body, sse_field_value,
9 };
10 use super::{
11 ERROR_BODY_PREVIEW_BYTES, McpHttpAuth, McpTransport, bounded_body_excerpt, mask_url_secrets,
12 };
13
14 const SSE_INBOUND_CHANNEL_CAPACITY: usize = 4;
15
16 pub(super) struct SseTransport {
17 pub(super) client: McpHttpClient,
18 pub(super) base_url: String,
19 pub(super) auth: McpHttpAuth,
20 pub(super) endpoint_url: Option<String>,
21 pub(super) receiver: tokio::sync::mpsc::Receiver<SseInbound>,
22 pub(super) sse_task: tokio::task::JoinHandle<()>,
23 }
24
25 pub(super) enum SseInbound {
26 Endpoint(String),
27 Message(Vec<u8>),
28 }
29
30 impl SseTransport {
31 pub(super) async fn connect(
32 client: McpHttpClient,
33 url: String,
34 auth: McpHttpAuth,
35 cancel_token: tokio_util::sync::CancellationToken,
36 endpoint_timeout: Duration,
37 ) -> Result<Self> {
38 let (tx, rx) = tokio::sync::mpsc::channel(SSE_INBOUND_CHANNEL_CAPACITY);
39 let client_clone = client.clone();
40 let url_clone = url.clone();
41 let auth_clone = auth.clone();
42 let wait_cancel_token = cancel_token.clone();
43
44 let sse_task = tokio::spawn(async move {
45 if cancel_token.is_cancelled() {
46 return;
47 }
48 use futures_util::FutureExt;
49 let result = std::panic::AssertUnwindSafe(Self::run_sse_loop(
50 client_clone,
51 url_clone,
52 auth_clone,
53 tx,
54 cancel_token,
55 ))
56 .catch_unwind()
57 .await;
58 match result {
59 Ok(res) => {
60 if let Err(e) = res {
61 tracing::error!("SSE loop error: {}", e);
62 }
63 }
64 Err(panic_err) => {
65 if let Some(msg) = panic_err.downcast_ref::<&str>() {
66 tracing::error!("SSE loop panicked: {}", msg);
67 } else if let Some(msg) = panic_err.downcast_ref::<String>() {
68 tracing::error!("SSE loop panicked: {}", msg);
69 } else {
70 tracing::error!("SSE loop panicked with unknown error");
71 }
72 }
73 }
74 });
75
76 let mut transport = Self {
77 client,
78 base_url: url,
79 auth,
80 endpoint_url: None,
81 receiver: rx,
82 sse_task,
83 };
84 transport
85 .wait_for_endpoint(&wait_cancel_token, endpoint_timeout)
86 .await?;
87 Ok(transport)
88 }
89
90 async fn run_sse_loop(
91 client: McpHttpClient,
92 url: String,
93 auth: McpHttpAuth,
94 tx: tokio::sync::mpsc::Sender<SseInbound>,
95 cancel_token: tokio_util::sync::CancellationToken,
96 ) -> Result<()> {
97 let headers = tokio::select! {
98 biased;
99 _ = cancel_token.cancelled() => {
100 anyhow::bail!("MCP SSE connect cancelled before authentication completed")
101 }
102 headers = auth.resolved_headers() => headers?,
103 };
104 let request = apply_safe_custom_headers(
105 with_default_mcp_http_headers(client.get(&url), false),
106 &headers,
107 );
108 let response = tokio::select! {
109 biased;
110 _ = cancel_token.cancelled() => {
111 anyhow::bail!("MCP SSE connect cancelled before the request completed")
112 }
113 response = client.send(request) => response.with_context(|| {
114 format!(
115 "MCP SSE connect failed (transport=http url={})",
116 mask_url_secrets(&url),
117 )
118 })?,
119 };
120 let status = response.status();
121 if !status.is_success() {
122 let body_excerpt = bounded_body_excerpt(response, ERROR_BODY_PREVIEW_BYTES).await;
123 let body_excerpt = auth.server_error_preview(&body_excerpt);
124 anyhow::bail!(
125 "MCP SSE rejected (transport=http url={} status={}): {}",
126 mask_url_secrets(&url),
127 status,
128 body_excerpt,
129 );
130 }
131
132 let mut stream = response.bytes_stream();
133 use futures_util::StreamExt;
134 // Raw byte buffer so a multi-byte UTF-8 char split across reads is not
135 // corrupted, and bounded so a separator-less server cannot OOM us.
136 let mut buffer: Vec<u8> = Vec::new();
137
138 loop {
139 if cancel_token.is_cancelled() {
140 tracing::debug!("SSE loop cancelled");
141 break;
142 }
143 let item = tokio::select! {
144 _ = cancel_token.cancelled() => {
145 tracing::debug!("SSE loop shutting down");
146 break;
147 }
148 item = stream.next() => {
149 match item {
150 Some(i) => i,
151 None => break,
152 }
153 }
154 };
155 let chunk = item?;
156 buffer.extend_from_slice(&chunk);
157 if buffer.len() > MAX_SSE_FRAME_BYTES {
158 anyhow::bail!(
159 "MCP SSE frame exceeded {} bytes without a separator — aborting",
160 MAX_SSE_FRAME_BYTES
161 );
162 }
163
164 while let Some((pos, separator_len)) = find_sse_event_separator_bytes(&buffer) {
165 // Complete block: decoding cannot split a multi-byte char.
166 let event_block = String::from_utf8_lossy(&buffer[..pos]).into_owned();
167 buffer.drain(..pos + separator_len);
168
169 let mut event_type = "message";
170 let mut data = String::new();
171
172 for line in event_block.lines() {
173 if let Some(value) = sse_field_value(line, "event:") {
174 event_type = value;
175 } else if let Some(value) = sse_field_value(line, "data:") {
176 if !data.is_empty() {
177 data.push('\n');
178 }
179 data.push_str(value);
180 }
181 }
182
183 let inbound = match event_type {
184 "endpoint" => Some(SseInbound::Endpoint(data)),
185 "message" if !data.trim().is_empty() => {
186 Some(SseInbound::Message(data.into_bytes()))
187 }
188 _ => None,
189 };
190 if let Some(inbound) = inbound {
191 let sent = tokio::select! {
192 biased;
193 _ = cancel_token.cancelled() => return Ok(()),
194 sent = tx.send(inbound) => sent,
195 };
196 if sent.is_err() {
197 return Ok(());
198 }
199 }
200 }
201 }
202 Ok(())
203 }
204
205 async fn wait_for_endpoint(
206 &mut self,
207 cancel_token: &tokio_util::sync::CancellationToken,
208 endpoint_timeout: Duration,
209 ) -> Result<()> {
210 let timeout = tokio::time::sleep(endpoint_timeout);
211 tokio::pin!(timeout);
212
213 let msg = tokio::select! {
214 _ = cancel_token.cancelled() => {
215 anyhow::bail!("SSE transport cancelled before endpoint was discovered");
216 }
217 _ = &mut timeout => {
218 anyhow::bail!(
219 "SSE endpoint not received within {}ms",
220 endpoint_timeout.as_millis()
221 );
222 }
223 msg = self.receiver.recv() => {
224 msg.context("SSE transport closed before endpoint was discovered")?
225 }
226 };
227
228 match msg {
229 SseInbound::Endpoint(endpoint) => self.store_endpoint(&endpoint),
230 SseInbound::Message(_) => {
231 anyhow::bail!("MCP SSE server sent a message before declaring its endpoint");
232 }
233 }
234 }
235
236 fn store_endpoint(&mut self, endpoint: &str) -> Result<()> {
237 self.endpoint_url = Some(Self::resolve_endpoint_url(&self.base_url, endpoint)?);
238 Ok(())
239 }
240
241 fn resolve_endpoint_url(base_url: &str, endpoint_url: &str) -> Result<String> {
242 let base = reqwest::Url::parse(base_url)?;
243 let resolved =
244 if endpoint_url.starts_with("http://") || endpoint_url.starts_with("https://") {
245 reqwest::Url::parse(endpoint_url)?
246 } else {
247 base.join(endpoint_url)?
248 };
249 // Security: the server-supplied `endpoint` event must stay same-origin
250 // as the connect URL. The connect host is vetted by network policy
251 // once, but the endpoint host is never re-checked — so an absolute
252 // cross-origin endpoint would let a malicious MCP server redirect the
253 // client's *authenticated* POSTs (Bearer/OAuth headers attached) to an
254 // internal host (169.254.169.254, localhost admin ports, …): an SSRF /
255 // policy bypass. Relative endpoints are same-origin by construction.
256 if resolved.scheme() != base.scheme()
257 || resolved.host_str() != base.host_str()
258 || resolved.port_or_known_default() != base.port_or_known_default()
259 {
260 anyhow::bail!(
261 "MCP SSE endpoint {} is not same-origin as {} — refusing to send \
262 authenticated requests cross-origin",
263 mask_url_secrets(resolved.as_str()),
264 mask_url_secrets(base.as_str()),
265 );
266 }
267 Ok(resolved.to_string())
268 }
269 }
270
271 #[async_trait::async_trait]
272 impl McpTransport for SseTransport {
273 async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
274 let endpoint = self
275 .endpoint_url
276 .as_ref()
277 .context("SSE endpoint not yet discovered")?
278 .clone();
279 let headers = self.auth.resolved_headers().await?;
280 let request = apply_safe_custom_headers(
281 with_default_mcp_http_headers(self.client.post(&endpoint), true),
282 &headers,
283 )
284 .body(msg);
285 let response = self.client.send(request).await.with_context(|| {
286 format!(
287 "MCP SSE POST send failed (transport=sse endpoint={})",
288 mask_url_secrets(&endpoint)
289 )
290 })?;
291 let status = response.status();
292 if !status.is_success() {
293 let body_excerpt = bounded_body_excerpt(response, ERROR_BODY_PREVIEW_BYTES).await;
294 let stale_session = is_mcp_stale_session_body(&body_excerpt);
295 let body_excerpt = self.auth.server_error_preview(&body_excerpt);
296 if stale_session {
297 anyhow::bail!(
298 "MCP session expired (transport=sse endpoint={} status={}): {}",
299 mask_url_secrets(&endpoint),
300 status,
301 body_excerpt
302 );
303 }
304 anyhow::bail!(
305 "MCP SSE POST rejected (transport=sse endpoint={} status={}): {}",
306 mask_url_secrets(&endpoint),
307 status,
308 body_excerpt
309 );
310 }
311 Ok(())
312 }
313
314 async fn recv(&mut self) -> Result<Vec<u8>> {
315 loop {
316 match self.receiver.recv().await.context("SSE transport closed")? {
317 SseInbound::Endpoint(endpoint) => {
318 self.store_endpoint(&endpoint)?;
319 }
320 SseInbound::Message(msg) => return Ok(msg),
321 }
322 }
323 }
324
325 async fn shutdown(&mut self) {
326 self.sse_task.abort();
327 }
328 }
329
330 impl Drop for SseTransport {
331 fn drop(&mut self) {
332 // Dropping a JoinHandle detaches its task. Abort explicitly so a
333 // cancelled connection cannot leave an auth refresh, connect, or SSE
334 // body stream running without an authority owner.
335 self.sse_task.abort();
336 }
337 }
338
339 #[cfg(test)]
340 mod endpoint_tests {
341 use std::time::Duration;
342
343 use super::{McpHttpAuth, McpHttpClient, SseInbound, SseTransport};
344
345 #[test]
346 fn resolve_endpoint_accepts_relative_and_same_origin() {
347 let base = "https://mcp.example.com/v1/sse";
348 // Relative path -> same origin.
349 assert_eq!(
350 SseTransport::resolve_endpoint_url(base, "/messages?sid=1").unwrap(),
351 "https://mcp.example.com/messages?sid=1"
352 );
353 // Absolute but same origin -> allowed.
354 assert_eq!(
355 SseTransport::resolve_endpoint_url(base, "https://mcp.example.com/messages").unwrap(),
356 "https://mcp.example.com/messages"
357 );
358 }
359
360 #[test]
361 fn resolve_endpoint_rejects_cross_origin_ssrf() {
362 let base = "https://mcp.example.com/v1/sse";
363 // Different host (metadata endpoint) -> rejected.
364 assert!(SseTransport::resolve_endpoint_url(base, "http://169.254.169.254/latest").is_err());
365 // Different scheme -> rejected.
366 assert!(
367 SseTransport::resolve_endpoint_url(base, "http://mcp.example.com/messages").is_err()
368 );
369 // Different port -> rejected.
370 assert!(
371 SseTransport::resolve_endpoint_url(base, "https://mcp.example.com:8443/x").is_err()
372 );
373 }
374
375 #[tokio::test]
376 async fn message_before_endpoint_is_rejected_instead_of_buffered() {
377 // Building a reqwest client needs the process-wide rustls provider;
378 // production installs it at startup, and this test must not depend
379 // on another test in the same process having done so first.
380 crate::tls::ensure_rustls_crypto_provider();
381 let (tx, rx) = tokio::sync::mpsc::channel(1);
382 tx.send(SseInbound::Message(br#"{"jsonrpc":"2.0"}"#.to_vec()))
383 .await
384 .unwrap();
385 let mut transport = SseTransport {
386 client: McpHttpClient::new(
387 "https://example.invalid/sse",
388 false,
389 false,
390 false,
391 None,
392 Duration::from_secs(10),
393 Duration::from_secs(120),
394 )
395 .unwrap(),
396 base_url: "https://example.invalid/sse".to_string(),
397 auth: McpHttpAuth::default(),
398 endpoint_url: None,
399 receiver: rx,
400 sse_task: tokio::spawn(async {}),
401 };
402
403 let error = transport
404 .wait_for_endpoint(
405 &tokio_util::sync::CancellationToken::new(),
406 Duration::from_secs(1),
407 )
408 .await
409 .expect_err("pre-endpoint message must fail closed");
410 assert!(error.to_string().contains("before declaring its endpoint"));
411 }
412 }
413
413 lines RUST