Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions internal/node/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/node/mcp_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,38 @@ 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) {
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.NewRequestWithContext(ctx, 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()
Expand Down
5 changes: 3 additions & 2 deletions internal/node/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
2 changes: 0 additions & 2 deletions internal/node/stdio_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion site/content/docs/reference/node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading