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) <noreply@anthropic.com>
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
)
|
|
|
|
const (
|
|
ProtocolVersion = "2025-03-26"
|
|
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
type ServerOptions struct {
|
|
Registry *registry.Registry
|
|
Sessions *SessionStore
|
|
}
|
|
|
|
type Server struct {
|
|
opts ServerOptions
|
|
}
|
|
|
|
func NewServer(opts ServerOptions) *Server {
|
|
if opts.Sessions == nil {
|
|
opts.Sessions = NewSessionStore()
|
|
}
|
|
return &Server{opts: opts}
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodHead:
|
|
w.Header().Set("MCP-Protocol-Version", ProtocolVersion)
|
|
w.WriteHeader(http.StatusOK)
|
|
case http.MethodGet:
|
|
s.handleGET(w, r)
|
|
case http.MethodPost:
|
|
s.handlePOST(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) // 1 MiB cap
|
|
var req Request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, NewErrorResponse(nil, -32700, "parse error", nil))
|
|
return
|
|
}
|
|
if req.ID == nil {
|
|
// Notification — no response.
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
|
|
if req.Method == "initialize" {
|
|
sid := s.opts.Sessions.Issue()
|
|
w.Header().Set("Mcp-Session-Id", sid)
|
|
writeJSON(w, http.StatusOK, NewResponse(req.ID, map[string]any{
|
|
"protocolVersion": ProtocolVersion,
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": "gitea-mcp", "version": "0.1.0"},
|
|
}))
|
|
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":
|
|
writeJSON(w, http.StatusOK, NewResponse(req.ID, map[string]any{
|
|
"tools": s.opts.Registry.Tools(),
|
|
}))
|
|
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
|
writeJSON(w, http.StatusOK,
|
|
NewErrorResponse(req.ID, -32602, "invalid params", nil))
|
|
return
|
|
}
|
|
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
|
|
if err != nil {
|
|
code := -32000
|
|
if errors.Is(err, registry.ErrToolNotFound) {
|
|
code = CodeNotFound
|
|
}
|
|
writeJSON(w, http.StatusOK,
|
|
NewErrorResponse(req.ID, code, err.Error(), nil))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, NewResponse(req.ID, map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": string(out)}},
|
|
}))
|
|
|
|
default:
|
|
writeJSON(w, http.StatusOK,
|
|
NewErrorResponse(req.ID, -32601, "method not found: "+req.Method, nil))
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
|
|
// Session ID is optional for GET: clients may open the SSE stream before
|
|
// calling initialize (e.g. claude.ai probes on add). Accept with or without.
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
flusher, _ := w.(http.Flusher)
|
|
// Emit a comment as keepalive; real notifications come via a future channel.
|
|
_, _ = w.Write([]byte(": stream open\n\n"))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
<-r.Context().Done()
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|