docs: MCP SSE custom server guide — express.json() pitfall and autoApprove troubleshooting#526
docs: MCP SSE custom server guide — express.json() pitfall and autoApprove troubleshooting#526willtwilson wants to merge 2 commits intoLibreChat-AI:mainfrom
Conversation
… troubleshooting - Add 'Building a Custom MCP SSE Server' section with a prominent warning callout explaining why express.json() must not be used with MCP SSE transport: body-parser drains the IncomingMessage stream before SSEServerTransport can read it, causing HTTP 400 on every initialize handshake and preventing tools from loading. - Include correct and incorrect Express/TypeScript patterns with clear comments. - Add 'Troubleshooting: Agent generates placeholder text instead of calling tools' section explaining the two root causes: missing autoApprove (stalled confirmation dialog) and MCP server not connected / tools not loading. These two issues together caused zero MCP tools to load in a production LibreChat deployment. Documenting them here to help other self-hosters avoid the same pitfall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@willtwilson is attempting to deploy a commit to the LibreChat's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
Adds troubleshooting guidance to the MCP Servers object-structure documentation to help diagnose “no tools loaded” scenarios in LibreChat SSE deployments.
Changes:
- Documented an Express
express.json()/ request-stream consumption pitfall when implementing custom MCP SSE servers. - Added a troubleshooting section for agents generating placeholder text instead of invoking tools, including
autoApproveconfiguration guidance.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| <Callout type="warning" title="Critical: Do NOT use express.json() with MCP SSE transport"> | ||
| `express.json()` uses body-parser internally, which reads and drains the Node.js `IncomingMessage` request body stream. `SSEServerTransport.handlePostMessage()` in the `@modelcontextprotocol/sdk` also reads this same stream to parse MCP protocol messages. | ||
|
|
||
| If `express.json()` runs first (as middleware always does), the stream is already exhausted when the MCP SDK tries to read it — resulting in **HTTP 400 errors on every MCP `initialize` handshake**, which silently prevents all tools from loading in LibreChat. |
There was a problem hiding this comment.
The statement "as middleware always does" is misleading in Express: middleware runs in registration order, and can be scoped to specific paths. Consider rephrasing to clarify the real failure mode (e.g., when express.json() is applied to /messages or registered before the MCP routes), and mention the safe alternative of scoping JSON parsing to non-MCP routes (e.g., app.use('/api', express.json())).
| If `express.json()` runs first (as middleware always does), the stream is already exhausted when the MCP SDK tries to read it — resulting in **HTTP 400 errors on every MCP `initialize` handshake**, which silently prevents all tools from loading in LibreChat. | |
| If `express.json()` is registered on the same routes as your MCP transport (for example on `/messages`) and runs before the MCP SDK handler, it will consume the request body stream so it is already exhausted when the MCP SDK tries to read it — resulting in **HTTP 400 errors on every MCP `initialize` handshake**, which silently prevents all tools from loading in LibreChat. | |
| To safely combine JSON APIs with MCP SSE transport in the same Express app, scope JSON parsing only to your non-MCP routes, for example: `app.use("/api", express.json());`. |
There was a problem hiding this comment.
You're right that 'as middleware always does' was too absolute — updated in 4c855a0. The revised text correctly scopes the failure: it only occurs when express.json() is registered globally or specifically on /messages before the MCP SDK handler runs. I've also added the safe alternative pattern (app.use('/api', express.json())) so users who need JSON APIs alongside MCP SSE transport in the same Express app can do so correctly.
| ``` | ||
|
|
||
| **Condition 2: Tools must be available in the agent session** | ||
|
|
There was a problem hiding this comment.
This cross-reference anchor looks broken: the target is a <Callout ... title="...">, which typically does not generate a markdown heading id/anchor. Consider linking to an actual heading (e.g., the "Building a Custom MCP SSE Server" section) or adding a dedicated heading for the express.json() pitfall that can be reliably linked to.
| ## Critical: Do not use express.json() with MCP SSE transport | |
| When using an MCP server over SSE, do not apply `express.json()` (or other body-parsing middleware) to the SSE endpoint route, as it can break the streaming connection and cause HTTP 400 errors on `/messages`. Ensure your SSE route is defined before JSON body parsers or is excluded from them. |
There was a problem hiding this comment.
Fixed in 4c855a0 — replaced the Callout cross-reference (which doesn't generate an anchor) with a proper ## heading so it can be linked to reliably. The section is now anchored as ## Critical: Do Not Use express.json() With MCP SSE Transport.
|
|
||
| app.post("/messages", async (req, res) => { | ||
| // SSEServerTransport.handlePostMessage() tries to read stream here | ||
| // but stream is already empty — HTTP 400 "stream is not readable" |
There was a problem hiding this comment.
In the "Incorrect Pattern" snippet, transport is referenced but never defined/initialized in the example. This makes the snippet harder to follow and can confuse readers trying to reproduce the failure; consider either showing where transport comes from (as in the correct pattern) or renaming/commenting it as an existing transport instance.
| // but stream is already empty — HTTP 400 "stream is not readable" | |
| // but stream is already empty — HTTP 400 "stream is not readable" | |
| // Assume `transport` is an existing SSEServerTransport instance, as in the correct pattern above. |
There was a problem hiding this comment.
Fixed in 4c855a0 — added a comment making clear that transport in the 'Incorrect Pattern' snippet refers to an existing SSEServerTransport instance as defined in the correct pattern above. Without that context it was genuinely confusing.
| }); | ||
| ``` | ||
|
|
||
| ### librechat.yaml configuration for a custom SSE server |
There was a problem hiding this comment.
The heading "librechat.yaml configuration for a custom SSE server" uses sentence-case, while nearby headings in this file use Title Case (e.g., "MCP Server with OAuth Authentication", "Correct Pattern"). Consider capitalizing it for consistency.
| ### librechat.yaml configuration for a custom SSE server | |
| ### librechat.yaml Configuration for a Custom SSE Server |
There was a problem hiding this comment.
Fixed in 4c855a0 — updated to Title Case to match surrounding headings: ### librechat.yaml Configuration for a Custom SSE Server
- Nuance express.json() warning (scoped middleware is safe) - Fix broken callout anchor with proper heading - Add transport variable context to incorrect pattern - Fix heading capitalisation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
This PR adds two practical troubleshooting sections to the MCP Servers Object Structure documentation, covering two issues that together caused zero MCP tools to load in a production LibreChat SSE deployment.
Part 1 — Express.json() causes HTTP 400 on every MCP initialize call
Root cause
Express.json() uses body-parser internally, which reads and drains the Node.js IncomingMessage request body stream. SSEServerTransport.handlePostMessage() in @modelcontextprotocol/sdk also reads this same stream to parse incoming MCP protocol messages.
When Express.json() is registered as middleware (which always runs first), the stream is exhausted before the MCP SDK can read it. This results in HTTP 400 errors on every MCP initialize handshake — silently preventing all tools from loading in LibreChat with no obvious error message in the UI.
What this PR adds
Part 2 — Agent generates placeholder text instead of calling tools
Root cause
When AutoApprove is not configured, LibreChat shows a confirmation dialog before each tool call. In some agent configurations this dialog is never presented or resolved, causing the agent to stall and instead generate placeholder text like [Fetching weather data...].
What this PR adds
Impact
Both issues are subtle and hard to diagnose from LibreChat logs alone. These two sections document what I found after debugging a working MCP SSE server that appeared configured correctly but loaded zero tools.