Skip to content

Fix MCP stdio framing and avoid UTF-8 slicing in Codex ingestion - #19

Open
paulolimajr77 wants to merge 3 commits into
bunkerlab-net:masterfrom
paulolimajr77:fix/mcp-framing-and-codex-utf8
Open

Fix MCP stdio framing and avoid UTF-8 slicing in Codex ingestion#19
paulolimajr77 wants to merge 3 commits into
bunkerlab-net:masterfrom
paulolimajr77:fix/mcp-framing-and-codex-utf8

Conversation

@paulolimajr77

@paulolimajr77 paulolimajr77 commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR contains two focused fixes that were reproduced locally while using MemPalace with modern MCP clients and Codex JSONL conversation imports.

1. Native MCP stdio framing support

mempalace mcp currently reads stdin line-by-line and writes raw JSON followed by \n. That works with legacy line-delimited JSON-RPC usage, but it fails with MCP clients that speak stdio using Content-Length framing.

The result is that a valid MCP initialize request gets parsed as if the Content-Length header were JSON, causing startup/handshake failure before tool discovery.

This patch updates the MCP server to:

  • autodetect legacy line-delimited JSON vs framed stdio transport
  • parse Content-Length headers
  • read request bodies by exact byte length
  • write framed responses when the client is using framed stdio
  • keep the legacy line-delimited mode for backward compatibility
  • add tests covering framed parsing/writing plus initialize, tools/list, and tools/call

2. Avoid UTF-8 slicing panics in Codex conversation ingestion

The conversation/room classification logic sliced content[..N] before lowercasing. Since Rust string slices are byte-indexed, this can panic when the text contains multibyte UTF-8 characters and the truncation point lands inside a code point.

This patch switches those hot paths to content.chars().take(N).collect::<String>(), which keeps the truncation character-safe.

The affected paths are used during conversation import / room detection, including Codex JSONL-style content with emoji and non-ASCII text.

Files changed

  • src/mcp/mod.rs
  • src/palace/convo_miner.rs
  • src/palace/room_detect.rs

Testing

Passed

  • cargo fmt --check

Added test coverage

  • framed MCP request parsing
  • framed MCP response writing
  • initialize response
  • tools/list
  • tools/call (mempalace_status)
  • UTF-8-safe room detection tests

Windows validation note

I also validated the MCP fix locally against a Codex setup using framed stdio messages and confirmed the server could complete initialize, list tools, and call tools successfully.

I was not able to complete cargo test / cargo build --release on this Windows host because the local x86_64-pc-windows-gnullvm toolchain is missing the configured linker (x86_64-w64-mingw32-clang). That appears to be an environment/toolchain issue rather than a code issue.

Out of scope

I used a local bridge script as a temporary environment workaround while validating Windows behavior, but that workaround is intentionally not included here. This PR contains only the native source fixes that make sense upstream.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented crashes when processing content with emoji and accented characters by safer UTF‑8 handling.
    • Improved message transport to reliably handle both line-delimited JSON and header-framed (Content-Length) messages, including consistent error responses.
  • Tests

    • Added tests for UTF‑8 room detection and for both message transport modes (including oversized-frame handling).

@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Refactored the MCP server stdio transport to detect and operate in either line-delimited JSON or Content-Length header-framed mode, with stateful mode selection on first input. Also replaced byte-slice truncation with Unicode-aware chars() truncation in two room-detection modules to avoid UTF-8 panics.

Changes

Cohort / File(s) Summary
MCP Transport Dual-Mode Implementation
src/mcp/mod.rs
Replaced single-line stdio transport with a stateful transport supporting two modes: line-delimited JSON and Content-Length framed. Added TransportMode detection on first non-empty line, async reading helpers for both modes (line skip/trim, header parsing with Content-Length and read_exact), unified write_response for both modes (with flushing), parse-error routing, and unit tests covering legacy and framed flows.
UTF-8 Safe Truncation for Room Detection
src/palace/convo_miner.rs, src/palace/room_detect.rs
Changed truncation to use content.chars().take(...).collect::<String>() before to_lowercase() to avoid slicing mid-multi-byte characters. Added unit tests with emoji/multi-byte input to validate behavior and prevent panics.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant StdioTransport as "Stdio Transport"
participant MCPHandler as "MCP JSON-RPC Handler"
Client->>StdioTransport: send first non-empty line/message
StdioTransport->>StdioTransport: detect mode (JSON-like vs MCP header)
Client->>StdioTransport: send request (line or framed)
StdioTransport->>MCPHandler: deliver parsed JSON request
MCPHandler->>MCPHandler: handle request (e.g., initialize/tools)
MCPHandler->>StdioTransport: return JSON response
StdioTransport->>Client: write response (line + newline OR Content-Length framed)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble bytes and sniff the mode,

Line or frame — whichever road.
I hop through chars, no slice will fail,
Emoji safe along the trail.
Hooray — the transport and rooms prevail!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: MCP stdio framing support and UTF-8-safe slicing in Codex ingestion, matching the actual changeset across all three modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/mcp/mod.rs`:
- Around line 164-168: The code currently uses body_len (from content_length)
directly to allocate vec![0_u8; body_len] and then reads into it, which allows a
hostile client to request huge allocations; add an explicit guard before
allocation: define and use a MAX_MCP_BODY_LEN constant (or configurable limit)
and validate content_length is present, non-negative, and <= MAX_MCP_BODY_LEN,
returning an error (e.g., Error::Other or a more specific PayloadTooLarge) if it
exceeds the limit; only after this check convert content_length to usize,
allocate the buffer (body), and call reader.read_exact(&mut body). Ensure you
reference the existing symbols body_len, content_length, and reader.read_exact
when applying the change.
- Around line 91-97: The code currently treats any non-JSON first_line as
HeaderFramed which causes malformed legacy payloads like "oops" to be routed to
parse_header_line; change the decision in the block that calls
looks_like_json(&first_line) so that TransportMode::HeaderFramed is set only
when the first_line actually looks like an MCP header (e.g., starts with a
header key such as "Content-Length:" or matches header syntax), otherwise keep
TransportMode::LineDelimitedJson and return the first_line for JSON parsing;
update the branch around TransportMode::HeaderFramed/read_framed_request and the
condition used to detect headers (introduce or reuse a helper such as
is_mcp_header(first_line) if needed) so non-header, non-JSON lines still get
routed into the JSON parse path used by run()/parse_header_line error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 290aa672-4202-4895-8c56-7fa147eca33c

📥 Commits

Reviewing files that changed from the base of the PR and between 49ad7f5 and 3da2936.

📒 Files selected for processing (3)
  • src/mcp/mod.rs
  • src/palace/convo_miner.rs
  • src/palace/room_detect.rs

Comment thread src/mcp/mod.rs Outdated
Comment thread src/mcp/mod.rs
@paulolimajr77

Copy link
Copy Markdown
Contributor Author

Addressing the two automated review findings in 31ba809:

  • Framing autodetection now only switches to header-framed mode when the first non-empty line is an actual MCP header (Content-Length), so malformed legacy input still flows through the existing JSON parse-error path instead of failing header parsing.
  • Framed request bodies are now bounded with MAX_MCP_BODY_LEN before allocation to avoid untrusted oversized Content-Length values forcing large allocations.
  • Added focused tests covering both cases.

I could not rerun cargo fmt --check / cargo test on this host because cargo is not installed in PATH here, so I am not claiming a local green run from this environment.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/mcp/mod.rs`:
- Around line 187-194: The Content-Length handling currently allows
last-one-wins; change the code in the if
name.eq_ignore_ascii_case("Content-Length") block to reject duplicates by
checking if content_length is already Some before parsing: if
content_length.is_some() return an Error::Other with a clear message about
duplicate Content-Length, otherwise parse value.trim().parse::<usize>() as
before and store into *content_length = Some(parsed); reference the
name.eq_ignore_ascii_case("Content-Length") branch, the content_length variable,
and return Error::Other on duplicate to avoid accepting conflicting headers.
- Around line 228-232: The transport detection helper looks_like_mcp_header
currently only recognizes "Content-Length" and thus misclassifies framed clients
that send "Content-Type" first; update looks_like_mcp_header (and any place that
relies on it) to treat either "Content-Length" or "Content-Type"
(case-insensitive) before the ':' as evidence of framed/MCP mode so the code
does not default to LineDelimitedJson when headers appear in any order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a238ead2-674a-4fb6-b173-3cf6f0faf9c4

📥 Commits

Reviewing files that changed from the base of the PR and between 3da2936 and 31ba809.

📒 Files selected for processing (1)
  • src/mcp/mod.rs

Comment thread src/mcp/mod.rs
Comment on lines +187 to +194
if name.eq_ignore_ascii_case("Content-Length") {
let parsed = value.trim().parse::<usize>().map_err(|e| {
Error::Other(format!(
"invalid Content-Length header value '{}': {e}",
value.trim()
))
})?;
*content_length = Some(parsed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject duplicate Content-Length headers.

This is currently last-one-wins. Conflicting duplicates can make the reader consume the wrong body length and leave the stream misaligned for the next request.

Suggested fix
     if name.eq_ignore_ascii_case("Content-Length") {
         let parsed = value.trim().parse::<usize>().map_err(|e| {
             Error::Other(format!(
                 "invalid Content-Length header value '{}': {e}",
                 value.trim()
             ))
         })?;
-        *content_length = Some(parsed);
+        if content_length.replace(parsed).is_some() {
+            return Err(Error::Other(
+                "duplicate Content-Length header in MCP request".to_string(),
+            ));
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if name.eq_ignore_ascii_case("Content-Length") {
let parsed = value.trim().parse::<usize>().map_err(|e| {
Error::Other(format!(
"invalid Content-Length header value '{}': {e}",
value.trim()
))
})?;
*content_length = Some(parsed);
if name.eq_ignore_ascii_case("Content-Length") {
let parsed = value.trim().parse::<usize>().map_err(|e| {
Error::Other(format!(
"invalid Content-Length header value '{}': {e}",
value.trim()
))
})?;
if content_length.replace(parsed).is_some() {
return Err(Error::Other(
"duplicate Content-Length header in MCP request".to_string(),
));
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mod.rs` around lines 187 - 194, The Content-Length handling currently
allows last-one-wins; change the code in the if
name.eq_ignore_ascii_case("Content-Length") block to reject duplicates by
checking if content_length is already Some before parsing: if
content_length.is_some() return an Error::Other with a clear message about
duplicate Content-Length, otherwise parse value.trim().parse::<usize>() as
before and store into *content_length = Some(parsed); reference the
name.eq_ignore_ascii_case("Content-Length") branch, the content_length variable,
and return Error::Other on duplicate to avoid accepting conflicting headers.

Comment thread src/mcp/mod.rs
Comment on lines +228 to +232
fn looks_like_mcp_header(line: &str) -> bool {
match line.split_once(':') {
Some((name, _)) => name.eq_ignore_ascii_case("Content-Length"),
None => false,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For MCP/LSP stdio framing, may Content-Typelegally appear beforeContent-Length, or must Content-Length be the first header line?

💡 Result:

  • LSP (stdio / base protocol): The spec says the header part “consists of header fields” and that Content-Length is required and Content-Type is supported, but it does not require any particular header ordering (it never says Content-Length must be the first line). So Content-Type may legally appear before Content-Length as long as Content-Length is present and the header section is correctly terminated with \r\n\r\n. [1]

  • MCP (stdio transport): MCP stdio does not use Content-Length / Content-Type framing at all in the current spec; messages are newline-delimited JSON-RPC (must not contain embedded newlines). [2]

Sources: [1] LSP 3.17 Specification – “Header Part” (microsoft.github.io) (microsoft.github.io), [2] MCP Specification (2025-11-25) – “Transports / stdio” (modelcontextprotocol.io)

Citations:


🏁 Script executed:

cd /tmp && find . -name "mod.rs" -path "*/mcp/*" 2>/dev/null | head -5

Repository: bunkerlab-net/mempalace

Length of output: 49


🏁 Script executed:

rg -A 10 -B 5 "looks_like_mcp_header" --type rust

Repository: bunkerlab-net/mempalace

Length of output: 1697


🏁 Script executed:

rg "TransportMode|read_request" src/mcp/mod.rs -A 3 -B 1

Repository: bunkerlab-net/mempalace

Length of output: 2766


Detect framed mode from Content-Type too.

The transport mode detection defaults to LineDelimitedJson when the first line fails the looks_like_mcp_header() check. If a framed client sends Content-Type: before Content-Length:, the helper returns false, causing the logic to incorrectly lock into line-delimited mode and fail to parse all subsequent framed messages.

Per the LSP specification, header order is not constrained—Content-Type may legally appear before Content-Length.

Suggested fix
 fn looks_like_mcp_header(line: &str) -> bool {
     match line.split_once(':') {
-        Some((name, _)) => name.eq_ignore_ascii_case("Content-Length"),
+        Some((name, _)) => {
+            let name = name.trim();
+            name.eq_ignore_ascii_case("Content-Length")
+                || name.eq_ignore_ascii_case("Content-Type")
+        }
         None => false,
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn looks_like_mcp_header(line: &str) -> bool {
match line.split_once(':') {
Some((name, _)) => name.eq_ignore_ascii_case("Content-Length"),
None => false,
}
fn looks_like_mcp_header(line: &str) -> bool {
match line.split_once(':') {
Some((name, _)) => {
let name = name.trim();
name.eq_ignore_ascii_case("Content-Length")
|| name.eq_ignore_ascii_case("Content-Type")
}
None => false,
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mod.rs` around lines 228 - 232, The transport detection helper
looks_like_mcp_header currently only recognizes "Content-Length" and thus
misclassifies framed clients that send "Content-Type" first; update
looks_like_mcp_header (and any place that relies on it) to treat either
"Content-Length" or "Content-Type" (case-insensitive) before the ':' as evidence
of framed/MCP mode so the code does not default to LineDelimitedJson when
headers appear in any order.

@rblaine95
rblaine95 self-requested a review April 9, 2026 13:42
@rblaine95

Copy link
Copy Markdown
Member

Hi @paulolimajr77, thank you so much for the pull request.

Once CodeRabbit is happy with everything I'm more than happy to take a look and get this merged.

@rblaine95

Copy link
Copy Markdown
Member

I've cherry-picked 3da2936 into #21

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants