Skip to content

Commit 2ff9290

Browse files
committed
fix: support Content-Length framing in stdio transport (Codex/Claude Desktop compatibility)
Codex and Claude Desktop use Content-Length framed stdio protocol (like LSP). Previous implementation only supported line-delimited JSON, causing handshake failure. Now auto-detects and responds in the same framing mode.
1 parent 94a9c06 commit 2ff9290

1 file changed

Lines changed: 88 additions & 13 deletions

File tree

apps/mcp-server/src/main.rs

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use server::McpServer;
2020
use serde::Deserialize;
2121
use serde_json::json;
2222
use std::{collections::HashMap, convert::Infallible, sync::Arc};
23-
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
23+
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
2424
use tokio::sync::{Mutex, mpsc};
2525
use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream};
2626
use uuid::Uuid;
@@ -229,6 +229,49 @@ async fn health_check() -> impl IntoResponse {
229229
(StatusCode::OK, "OK")
230230
}
231231

232+
/// Whether the line is a Content-Length or Content-Type header (case-insensitive).
233+
fn is_stdio_header_line(line: &str) -> bool {
234+
let lower = line.to_ascii_lowercase();
235+
lower.starts_with("content-length:") || lower.starts_with("content-type:")
236+
}
237+
238+
/// Parse content-length value from a header line.
239+
fn parse_content_length(line: &str) -> Option<usize> {
240+
let lower = line.to_ascii_lowercase();
241+
if lower.starts_with("content-length:") {
242+
line[15..].trim().parse::<usize>().ok()
243+
} else {
244+
None
245+
}
246+
}
247+
248+
/// Whether we used Content-Length framing or line-delimited.
249+
#[derive(Copy, Clone)]
250+
enum StdioFrame {
251+
LineDelimited,
252+
ContentLength,
253+
}
254+
255+
async fn write_stdio_response(
256+
stdout: &mut tokio::io::Stdout,
257+
response_json: &str,
258+
frame: StdioFrame,
259+
) -> anyhow::Result<()> {
260+
match frame {
261+
StdioFrame::LineDelimited => {
262+
stdout.write_all(response_json.as_bytes()).await?;
263+
stdout.write_all(b"\n").await?;
264+
}
265+
StdioFrame::ContentLength => {
266+
let header = format!("Content-Length: {}\r\n\r\n", response_json.len());
267+
stdout.write_all(header.as_bytes()).await?;
268+
stdout.write_all(response_json.as_bytes()).await?;
269+
}
270+
}
271+
stdout.flush().await?;
272+
Ok(())
273+
}
274+
232275
async fn run_stdio(client: OpenPrClient) -> anyhow::Result<()> {
233276
tracing::info!("MCP stdio transport started");
234277

@@ -245,41 +288,73 @@ async fn run_stdio(client: OpenPrClient) -> anyhow::Result<()> {
245288
break;
246289
}
247290
Ok(_) => {
248-
let line = line.trim();
249-
if line.is_empty() {
291+
let trimmed = line.trim();
292+
if trimmed.is_empty() {
250293
continue;
251294
}
252295

253-
tracing::debug!(request = %line, "Received request");
296+
// Detect Content-Length framing (used by Codex, Claude Desktop)
297+
let (payload, frame) = if is_stdio_header_line(trimmed) {
298+
// Read headers until empty line
299+
let mut content_length: Option<usize> = parse_content_length(trimmed);
300+
loop {
301+
let mut header_line = String::new();
302+
match reader.read_line(&mut header_line).await {
303+
Ok(0) => break,
304+
Ok(_) => {
305+
let ht = header_line.trim();
306+
if ht.is_empty() {
307+
break; // End of headers
308+
}
309+
if content_length.is_none() {
310+
content_length = parse_content_length(ht);
311+
}
312+
}
313+
Err(_) => break,
314+
}
315+
}
316+
let cl = content_length.unwrap_or(0);
317+
if cl == 0 {
318+
continue;
319+
}
320+
let mut body = vec![0u8; cl];
321+
if let Err(e) = reader.read_exact(&mut body).await {
322+
tracing::error!(error = %e, "Failed to read Content-Length body");
323+
continue;
324+
}
325+
(body, StdioFrame::ContentLength)
326+
} else {
327+
// Line-delimited JSON
328+
(trimmed.as_bytes().to_vec(), StdioFrame::LineDelimited)
329+
};
254330

255-
let request: JsonRpcRequest = match serde_json::from_str(line) {
331+
let request: JsonRpcRequest = match serde_json::from_slice(&payload) {
256332
Ok(req) => req,
257333
Err(e) => {
258334
tracing::error!(error = %e, "Failed to parse request");
259335
let error_response = JsonRpcResponse::error(
260336
None,
261337
protocol::JsonRpcError::parse_error(format!("Invalid JSON: {}", e)),
262338
);
263-
if let Ok(response_json) = serde_json::to_string(&error_response) {
264-
stdout.write_all(response_json.as_bytes()).await?;
265-
stdout.write_all(b"\n").await?;
266-
stdout.flush().await?;
339+
if let Ok(rj) = serde_json::to_string(&error_response) {
340+
let _ = write_stdio_response(&mut stdout, &rj, frame).await;
267341
}
268342
continue;
269343
}
270344
};
271345

346+
tracing::debug!(method = %request.method, "Received request");
347+
272348
let response = server.handle_request(request).await;
273349
let Some(response) = response else {
274350
continue;
275351
};
276352

277353
match serde_json::to_string(&response) {
278354
Ok(response_json) => {
279-
tracing::debug!(response = %response_json, "Sending response");
280-
stdout.write_all(response_json.as_bytes()).await?;
281-
stdout.write_all(b"\n").await?;
282-
stdout.flush().await?;
355+
if let Err(e) = write_stdio_response(&mut stdout, &response_json, frame).await {
356+
tracing::error!(error = %e, "Failed to write response");
357+
}
283358
}
284359
Err(e) => {
285360
tracing::error!(error = %e, "Failed to serialize response");

0 commit comments

Comments
 (0)