#13775·outline

MCP endpoint sends a 400 response to `notifications/initialized`. No client can start a session.

Author: heikkimadeCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbugmore information neededself-hostedmcp

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 initialized notification 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

  1. Configure Claude Code for https://<outline>/mcp.
  2. Do the OAuth flow. Registration, consent and the token exchange all work correctly.
  3. The client sends initialize. Outline sends a 200 response.
  4. The client sends notifications/initialized. Outline sends a 400 response.
  5. The client stops the session.

Environment

markdown
- 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:

json
{"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:

nginx
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;
}