-
Notifications
You must be signed in to change notification settings - Fork 363
docs: MCP SSE custom server guide — express.json() pitfall and autoApprove troubleshooting #526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -634,6 +634,105 @@ The `mcpServers` configurations allow LibreChat to dynamically interact with var | |||||||||||
| - OAuth2 flow is supported for secure authentication with MCP servers | ||||||||||||
| - Users will be prompted to authenticate via OAuth before the MCP server can be used | ||||||||||||
|
|
||||||||||||
| ## Building a Custom MCP SSE Server | ||||||||||||
|
|
||||||||||||
| When implementing a custom MCP SSE server in Node.js/Express to use with LibreChat, **do not add `express.json()` middleware** to the same Express instance that handles MCP transport. | ||||||||||||
|
|
||||||||||||
| <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. | ||||||||||||
| </Callout> | ||||||||||||
|
|
||||||||||||
| ### Correct Pattern | ||||||||||||
|
|
||||||||||||
| ```typescript | ||||||||||||
| import express from "express"; | ||||||||||||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||||||||||||
| import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; | ||||||||||||
|
|
||||||||||||
| const app = express(); | ||||||||||||
| // Do NOT add: app.use(express.json()); | ||||||||||||
| // Do NOT add: app.use(bodyParser.json()); | ||||||||||||
|
|
||||||||||||
| const transports = new Map<string, SSEServerTransport>(); | ||||||||||||
|
|
||||||||||||
| app.get("/sse", async (req, res) => { | ||||||||||||
| const transport = new SSEServerTransport("/messages", res); | ||||||||||||
| transports.set(transport.sessionId, transport); | ||||||||||||
| const server = buildMcpServer(); // your McpServer setup | ||||||||||||
| await server.connect(transport); | ||||||||||||
| res.on("close", () => transports.delete(transport.sessionId)); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| app.post("/messages", async (req, res) => { | ||||||||||||
| const id = req.query.sessionId as string; | ||||||||||||
| const transport = transports.get(id); | ||||||||||||
| if (!transport) { res.status(404).json({ error: "Session not found" }); return; } | ||||||||||||
| await transport.handlePostMessage(req, res); // reads raw stream internally | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| app.get("/health", (_req, res) => res.json({ status: "ok" })); | ||||||||||||
| app.listen(8932); | ||||||||||||
| ``` | ||||||||||||
|
|
||||||||||||
| ### Incorrect Pattern (causes HTTP 400 on all MCP calls) | ||||||||||||
|
|
||||||||||||
| ```typescript | ||||||||||||
| const app = express(); | ||||||||||||
| app.use(express.json()); // ❌ This drains the request stream! | ||||||||||||
|
|
||||||||||||
| app.post("/messages", async (req, res) => { | ||||||||||||
| // SSEServerTransport.handlePostMessage() tries to read stream here | ||||||||||||
| // but stream is already empty — HTTP 400 "stream is not readable" | ||||||||||||
|
||||||||||||
| // 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 4c855a0 — updated to Title Case to match surrounding headings: ### librechat.yaml Configuration for a Custom SSE Server
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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/messagesor 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())).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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/messagesbefore 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.