mcp: negotiate protocol version (#112)

Negotiate the protocol version properly, and set the header on HTTP
transports.

We follow the logic of the Typescript SDK.

- On initialization, the client sends the latest version it can support,
and accepts the version that the server returns if the client supports
it. If not, the connection fails.

- The server accepts the client's version unless it doesn't support it,
in which case it replies with its latest version.

Fixes #103.
This commit is contained in:
Jonathan Amsterdam 2025-07-10 11:06:55 -04:00 • committed by GitHub
commit c037ba51d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -18,6 +18,11 @@ import (
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
)
const (
protocolVersionHeader = "Mcp-Protocol-Version"
sessionIDHeader = "Mcp-Session-Id"
)
// A StreamableHTTPHandler is an http.Handler that serves streamable MCP
// sessions, as defined by the [MCP spec].
//
@ -88,7 +93,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
}
var session *StreamableServerTransport
if id := req.Header.Get("Mcp-Session-Id"); id != "" {
if id := req.Header.Get(sessionIDHeader); id != "" {
h.sessionsMu.Lock()
session, _ = h.sessions[id]
h.sessionsMu.Unlock()
@ -386,7 +391,7 @@ func (t *StreamableServerTransport) streamResponse(w http.ResponseWriter, req *h
t.mu.Unlock()
}
w.Header().Set("Mcp-Session-Id", t.id)
w.Header().Set(sessionIDHeader, t.id)
w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler]
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
@ -636,12 +641,19 @@ type streamableClientConn struct {
closeOnce sync.Once
closeErr error
mu sync.Mutex
_sessionID string
mu sync.Mutex
protocolVersion string
_sessionID string
// bodies map[*http.Response]io.Closer
err error
}
func (c *streamableClientConn) setProtocolVersion(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.protocolVersion = s
}
func (c *streamableClientConn) SessionID() string {
c.mu.Lock()
defer c.mu.Unlock()
@ -707,8 +719,11 @@ func (s *streamableClientConn) postMessage(ctx context.Context, sessionID string
if err != nil {
return "", err
}
if s.protocolVersion != "" {
req.Header.Set(protocolVersionHeader, s.protocolVersion)
}
if sessionID != "" {
req.Header.Set("Mcp-Session-Id", sessionID)
req.Header.Set(sessionIDHeader, sessionID)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
@ -724,7 +739,7 @@ func (s *streamableClientConn) postMessage(ctx context.Context, sessionID string
return "", fmt.Errorf("broken session: %v", resp.Status)
}
sessionID = resp.Header.Get("Mcp-Session-Id")
sessionID = resp.Header.Get(sessionIDHeader)
if resp.Header.Get("Content-Type") == "text/event-stream" {
go s.handleSSE(resp)
} else {
@ -763,7 +778,11 @@ func (s *streamableClientConn) Close() error {
if err != nil {
s.closeErr = err
} else {
req.Header.Set("Mcp-Session-Id", s._sessionID)
// TODO(jba): confirm that we don't need a lock here, or add locking.
if s.protocolVersion != "" {
req.Header.Set(protocolVersionHeader, s.protocolVersion)
}
req.Header.Set(sessionIDHeader, s._sessionID)
if _, err := s.client.Do(req); err != nil {
s.closeErr = err
}