MCP endpoint sends a 400 response to `notifications/initialized`. No client can start a session.
Is there an existing issue for this?
- I have searched the existing issues
This is not related to configuring Outline
- The issue is not related to self-hosting config
Current Behavior
Claude Code cannot use Outline as an MCP server. The OAuth sign-in is succesful, but the session stops immediately. There is no errors, just failed to connect.
Outline stops the session and refuses one message of the standard start sequence. Outline writes no log message for this refusal, also at the debug log level.
Expected Behavior
The start sequence finishes and the session opens, so Claude Code lists the Outline tools.
We think the fix is for POST /mcp to answer notifications/initialized with a 202. The MCP specification asks for that in two places:
- Lifecycle, Initialization — "After successful initialization, the client MUST send an
initializednotification to indicate it is ready to begin normal operations". - Transports, Sending Messages to the Server — "If the input is a JSON-RPC response or notification: If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body".
Steps To Reproduce
- Configure Claude Code for
https://<outline>/mcp. - Do the OAuth flow. Registration, consent and the token exchange all work correctly.
- The client sends
initialize. Outline sends a 200 response. - The client sends
notifications/initialized. Outline sends a 400 response. - The client stops the session.
Environment
- Outline 1.10.1. The fault is also in 1.9.2 and 1.8.2.
- `@modelcontextprotocol/sdk` 1.29.0 and `@hono/node-server` 1.19.17
- Self-hosted with Docker, Postgres 16 and Redis
- An nginx reverse proxy terminates TLS. It sends HTTP/1.1 requests to Outline.
- Our client is Claude Code 2.1.267. It uses a direct streamable HTTP transport.Anything else?
Observed exchange
A reverse proxy recorded these four requests:
| # | Request | Status | Content-Type | Body |
|---|---|---|---|---|
| 1 | POST /mcp (MCP-Protocol-Version: 2026-07-28) |
400 | application/json |
198 B |
| 2 | POST /mcp initialize |
200 | text/event-stream |
1688 B |
| 3 | POST /mcp notifications/initialized |
400 | (none) | 5 B |
| 4 | GET /mcp |
405 | application/json |
58 B |
Request 1 is a test of the protocol version. This behavior is correct, and Outline writes a log message for it. Request 1 is not the fault. Do not change the protocol version.
Request 3 is the fault. Its body is exactly this, with a Content-Length of 54:
{"jsonrpc":"2.0","method":"notifications/initialized"}The 400 response has no Content-Type header. The 5 response bytes are the 0\r\n\r\n terminator of a chunked response. Therefore the response body is empty.
Two clients fail at request 3:
- Claude Code 2.1.267 — our client, on a direct streamable HTTP transport
- mcp-remote — a different MCP SDK and a different OAuth implementation
Workaround
The reverse proxy sends the 202 response for the notification. It does not send the notification to Outline. A full session then works. initialize, tools/list and tool calls all work correctly.
This is the full OpenResty configuration that we use:
location = /mcp {
# get_body_data() gives nil if the body goes to a temporary file. Therefore
# keep the buffer larger than a notification.
client_body_buffer_size 256k;
access_by_lua_block {
if ngx.req.get_method() ~= "POST" then
return
end
local ctype = ngx.var.http_content_type or ""
if not ctype:find("application/json", 1, true) then
return
end
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
-- The body is in a temporary file. Thus it is too large for a notification.
return
end
local decoded = require("cjson.safe").decode(body)
if type(decoded) ~= "table" then
-- Outline must refuse bad JSON, not the proxy.
return
end
local function is_notification(msg)
return type(msg) == "table" and msg.method ~= nil and msg.id == nil
end
if decoded[1] ~= nil then
-- This is a batch. Send a 202 response only if each member is a
-- notification. Outline must answer a batch that has a request.
for _, msg in ipairs(decoded) do
if not is_notification(msg) then
return
end
end
return ngx.exit(202)
end
if is_notification(decoded) then
return ngx.exit(202)
end
}
proxy_pass http://outline_upstream;
proxy_http_version 1.1;
# The endpoint sends server-sent events. Therefore do not buffer the response,
# and make the read timeout longer than an idle stream.
proxy_buffering off;
proxy_read_timeout 1h;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}Source: outline/outline