From 4b717b83f51fa95376426f839c7a7d63a22064db Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Mon, 28 Sep 2026 20:52:24 +0000 Subject: [PATCH 1/4] node: serve MCP stateless MCP 2026-07-28 removed sessions, and the SDK serves it over HTTP only in stateless mode. A stateful handler also refused legacy-version clients that already send the per-request _meta protocol version tag, with -32022 for a version it lists as supported. Nothing on the node used sessions: no server-initiated notifications, no server-to-client requests, and the per-agent server is chosen per request. --- internal/node/mcp.go | 11 +++++++--- internal/node/mcp_handlers_test.go | 29 +++++++++++++++++++++++++ internal/node/mcp_test.go | 5 +++-- site/content/docs/reference/node-api.md | 2 +- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/node/mcp.go b/internal/node/mcp.go index de44fa25..6b1ee90c 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -148,13 +148,18 @@ func NewUnauthenticatedMCPServer(controlPlaneURL string) *mcp.Server { return mcpServer } +// MCP 2026-07-28 removed sessions, and the SDK serves it over HTTP only in +// stateless mode; a stateful server also refuses legacy clients that already +// send the new per-request _meta version tag. +var streamableOptions = &mcp.StreamableHTTPOptions{Stateless: true} + // NewUnauthenticatedMCPHandler creates an HTTP handler for the unauthenticated MCP server. func NewUnauthenticatedMCPHandler(controlPlaneURL string) http.Handler { mcpServer := NewUnauthenticatedMCPServer(controlPlaneURL) streamableHandler := mcp.NewStreamableHTTPHandler(func(request *http.Request) *mcp.Server { return mcpServer - }, nil) + }, streamableOptions) mux := http.NewServeMux() mux.Handle("/mcp", streamableHandler) @@ -171,10 +176,10 @@ func NewMCPHandler(node *SamNode) http.Handler { // Per agent, not per node: the SDK gives a tool handler the session's // context rather than the request's, so the only place to bind who the - // session belongs to is where the session's server is chosen. + // request belongs to is where its server is chosen. streamableHandler := mcp.NewStreamableHTTPHandler(func(request *http.Request) *mcp.Server { return servers.forAgent(agentFromLocalGateway(request)) - }, nil) + }, streamableOptions) mux := http.NewServeMux() mux.Handle("/mcp", streamableHandler) diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index ade4e0de..207602eb 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -830,6 +830,35 @@ func TestNewMCPHandler_RegistersFindRemoteTools(t *testing.T) { } } +// A legacy-version client that also sends the 2026-07-28 per-request _meta +// tag must be served, not refused as a new-protocol request; a stateful SDK +// handler answers it with -32022. +func TestNewMCPHandler_AcceptsLegacyRequestWithProtocolVersionMeta(t *testing.T) { + srv := httptest.NewServer(NewMCPHandler(&SamNode{BiscuitTimeout: 500 * time.Millisecond})) + defer srv.Close() + + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"tc","version":"0.0.1"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25"}}}` + req, err := http.NewRequest(http.MethodPost, srv.URL+"/mcp", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", "2025-11-25") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + out, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || !strings.Contains(string(out), `"protocolVersion":"2025-11-25"`) { + t.Fatalf("initialize with _meta protocol version tag: status %d body %s", resp.StatusCode, out) + } + if resp.Header.Get("Mcp-Session-Id") != "" { + t.Errorf("stateless handler must not issue Mcp-Session-Id, got %q", resp.Header.Get("Mcp-Session-Id")) + } +} + func TestHandleDescribeRemoteTool_EmptyPeerID(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() diff --git a/internal/node/mcp_test.go b/internal/node/mcp_test.go index 41dfbf63..4447d40c 100644 --- a/internal/node/mcp_test.go +++ b/internal/node/mcp_test.go @@ -70,8 +70,9 @@ func TestMCPHandler_HTTP(t *testing.T) { } defer func() { _ = resp2.Body.Close() }() - if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusBadRequest { - t.Errorf("Expected status OK or BadRequest on /mcp, got %d", resp2.StatusCode) + // Stateless servers have no standalone SSE stream; the spec reserves 405 for that. + if resp2.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("Expected status MethodNotAllowed on GET /mcp, got %d", resp2.StatusCode) } } diff --git a/site/content/docs/reference/node-api.md b/site/content/docs/reference/node-api.md index a937a09e..071a59e9 100644 --- a/site/content/docs/reference/node-api.md +++ b/site/content/docs/reference/node-api.md @@ -30,7 +30,7 @@ proxy path does not accept it there. |---|---|---| | `GET /healthz`, `GET /readyz` | none | `200` while the process is up. Every other route except `/debug/*` answers `503` until the node is connected to the mesh, so a `503` on `/mcp` is the practical readiness signal. | | `GET /metrics` | token | Prometheus metrics (`sam_node_*`). | -| `POST /mcp` | token | The MCP server (Streamable HTTP). `/` is an alias. | +| `POST /mcp` | token | The MCP server (Streamable HTTP, sessionless: no `Mcp-Session-Id`, `GET` answers `405`). `/` is an alias. | | `GET /v1/models` | token | Models served by every reachable inference provider. | | `POST /v1/chat/completions`, `POST /v1/completions` | token | OpenAI-compatible inference, routed to a provider of the requested model. | | `GET /sam/service/discover` | token | Discover services on the mesh. | From bbbdb31791b61890b5d68570a5a8d7e68aa28db6 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Mon, 28 Sep 2026 21:23:04 +0000 Subject: [PATCH 2/4] node: bound the stateless MCP test with a context --- internal/node/mcp_handlers_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 207602eb..7fab2568 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -834,11 +834,14 @@ func TestNewMCPHandler_RegistersFindRemoteTools(t *testing.T) { // tag must be served, not refused as a new-protocol request; a stateful SDK // handler answers it with -32022. func TestNewMCPHandler_AcceptsLegacyRequestWithProtocolVersionMeta(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + srv := httptest.NewServer(NewMCPHandler(&SamNode{BiscuitTimeout: 500 * time.Millisecond})) defer srv.Close() body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"tc","version":"0.0.1"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25"}}}` - req, err := http.NewRequest(http.MethodPost, srv.URL+"/mcp", strings.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", strings.NewReader(body)) if err != nil { t.Fatal(err) } From bda3973c583330282e171681058e98cf75021ab0 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Tue, 29 Sep 2026 10:43:02 +0000 Subject: [PATCH 3/4] tests: GET /mcp answers 405 on the sessionless node The stateless MCP handler has no standalone SSE stream, so GET /mcp is 405 Method Not Allowed instead of the 400 the stateful handler returned. The unit test in internal/node was updated; this integration test still expected 400 and failed in CI. --- tests/integration/auth_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/auth_test.go b/tests/integration/auth_test.go index 39aa533c..663f87d8 100644 --- a/tests/integration/auth_test.go +++ b/tests/integration/auth_test.go @@ -101,7 +101,8 @@ func TestNodeAuthEnforcementIntegration(t *testing.T) { // /sam/service/discover expects node to be connected. {"discover with token", "GET", "/sam/service/discover?type=mcp&name=test", http.StatusOK, true}, - {"mcp root with token", "GET", "/mcp", http.StatusBadRequest, true}, + // The sessionless MCP server has no standalone stream: GET is 405. + {"mcp root with token", "GET", "/mcp", http.StatusMethodNotAllowed, true}, } for _, tt := range tests { From 1b1b633e3b8ee6b130825e0efeb6045af24f9344 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Tue, 29 Sep 2026 10:46:53 +0000 Subject: [PATCH 4/4] node: stdio bridge stops minting a session id The bridge holds no per-caller state: one id space, no broadcast side. A sessionless server does not mint or echo Mcp-Session-Id, and the node's own /mcp handler no longer does. Nothing reads the constant value it sent. --- internal/node/stdio_bridge.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/node/stdio_bridge.go b/internal/node/stdio_bridge.go index f89f7360..6a72e6b4 100644 --- a/internal/node/stdio_bridge.go +++ b/internal/node/stdio_bridge.go @@ -206,8 +206,6 @@ func (b *StdioBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Mcp-Session-Id", "stdio-bridge") - if !isCall { w.WriteHeader(http.StatusAccepted) return