From 174669b9f6fc07f6db0727262122adcf178865aa Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 12 May 2026 14:57:52 +0200 Subject: [PATCH] fix(mcp): drop strict session-id requirement on POST /mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude.ai connector's MCP transport proxy does not reliably propagate the Mcp-Session-Id header issued during initialize. With the previous strict gate (return 400 plain text "missing or invalid Mcp-Session-Id"), every tools/list and tools/call from claude.ai failed and the Anthropic proxy surfaced it as: Streamable HTTP error: {"jsonrpc":"2.0","id":N,"error": {"code":-32600,"message":"Anthropic Proxy: Invalid content from server"}} — because the plain-text 400 response is not valid JSON-RPC. All tools the gitea-mcp server exposes are stateless single-shot calls, so there is no functional reason to gate them on a session. brain-mcp and supervisor-mcp don't gate either, and claude.ai works against them fine. Match that behavior: keep issuing Mcp-Session-Id on initialize for clients that want to use it, but stop rejecting calls that don't send one back. Test renamed PostWithoutSessionRejected → PostWithoutSessionAccepted and updated to assert the tools/list response shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/mcp/server.go | 12 ++++++------ internal/mcp/server_test.go | 12 ++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7db44d9..71cbba0 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -56,7 +56,6 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) { return } - // initialize is the only method allowed without a session. if req.Method == "initialize" { sid := s.opts.Sessions.Issue() w.Header().Set("Mcp-Session-Id", sid) @@ -68,11 +67,12 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) { return } - sid := r.Header.Get("Mcp-Session-Id") - if !s.opts.Sessions.Valid(sid) { - http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest) - return - } + // Mcp-Session-Id is advisory: we issue one on initialize and accept it back, + // but every tool the gitea-mcp server exposes is stateless single-shot, so + // we do not gate non-initialize calls on it. The claude.ai connector's + // transport proxy is observed to not propagate the session header reliably, + // and the spec allows servers to be sessionless. Compare with brain-mcp / + // supervisor-mcp, which never required a session at all. switch req.Method { case "tools/list": diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 15f15fe..f6809d0 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -57,14 +57,22 @@ func TestInitialize(t *testing.T) { assert.Equal(t, "gitea-mcp", si["name"]) } -func TestPostWithoutSessionRejected(t *testing.T) { +func TestPostWithoutSessionAccepted(t *testing.T) { + // gitea-mcp tools are stateless single-shot; Mcp-Session-Id is advisory. + // claude.ai's MCP transport proxy is observed to not propagate the + // session header reliably, so non-initialize calls must work without it. srv := newServer(t) rr := postJSON(t, srv, map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", }, "") - require.Equal(t, http.StatusBadRequest, rr.Code) + require.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + result := resp["result"].(map[string]any) + assert.Contains(t, result, "tools") } func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {