Skip to content

Commit aff2467

Browse files
authored
fix(mcp): set transport=streamable-http in wire-mcp; revert GET SSE (#20)
The wire-mcp gateway RPC was writing mcp.servers.<name> = {url} without a transport field. openclaw's pi-bundle-mcp-runtime defaults to the legacy SSEClientTransport in that case, which requires a long-lived GET SSE stream and blocks on an initial "event: endpoint" message. sluice's gateway is Streamable HTTP (POST-only, no server-initiated events), so the client hung for 30s and gave up. Explicitly setting transport: "streamable-http" makes bundle-mcp use StreamableHTTPClientTransport which does POST-only init and works cleanly. Also revert the GET SSE handler added earlier in this series: the keepalive-comments-only stream caused bundle-mcp to block because the legacy SSE transport was waiting for an "event: endpoint" frame. With the transport fix above, the client no longer hits the GET path; 405 on GET is spec-valid for Streamable HTTP and produces no warnings in the bundle-mcp runtime when the client uses Streamable HTTP directly.
1 parent ad14509 commit aff2467

4 files changed

Lines changed: 37 additions & 81 deletions

File tree

internal/container/gateway_rpc.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,23 @@ req.on("upgrade", (_res, socket) => {
319319
clearTimeout(deadline);
320320
fail("config.get response missing hash");
321321
}
322-
// Step 2 of wire-mcp: merge-patch mcp.servers.<name> = {url}.
322+
// Step 2 of wire-mcp: merge-patch mcp.servers.<name>. We must
323+
// set transport: "streamable-http" explicitly because openclaw's
324+
// pi-bundle-mcp-runtime defaults to the legacy SSE transport
325+
// when the field is omitted, and the SSE transport requires a
326+
// long-lived GET stream that sluice's gateway does not expose.
323327
// restartDelayMs gives us time to receive the response and exit
324328
// cleanly before the gateway restarts and kills our docker exec
325329
// (which would otherwise result in exit code 137).
326330
const raw = JSON.stringify({
327-
mcp: { servers: { [wireMcpName]: { url: wireMcpURL } } },
331+
mcp: {
332+
servers: {
333+
[wireMcpName]: {
334+
transport: "streamable-http",
335+
url: wireMcpURL,
336+
},
337+
},
338+
},
328339
});
329340
step = "sent-method";
330341
socket.write(

internal/mcp/server_http.go

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -33,59 +33,25 @@ func NewMCPHTTPHandler(gw *Gateway) *MCPHTTPHandler {
3333
return &MCPHTTPHandler{gw: gw}
3434
}
3535

36-
// ServeHTTP dispatches POST, GET, and DELETE requests for the MCP
37-
// Streamable HTTP protocol.
36+
// ServeHTTP dispatches POST and DELETE requests for the MCP Streamable
37+
// HTTP protocol. GET returns 405 explicitly: the MCP spec allows servers
38+
// to optionally expose a GET SSE stream for server-initiated events,
39+
// but openclaw's bundle-mcp client blocks waiting for events on the
40+
// stream. Returning 405 tells the client to use POST-only mode, which
41+
// matches how sluice's gateway actually works (request-response, no
42+
// unsolicited events).
3843
func (h *MCPHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3944
switch r.Method {
4045
case http.MethodPost:
4146
h.handlePost(w, r)
42-
case http.MethodGet:
43-
h.handleGet(w, r)
4447
case http.MethodDelete:
4548
h.handleDelete(w, r)
4649
default:
47-
w.Header().Set("Allow", "POST, GET, DELETE")
50+
w.Header().Set("Allow", "POST, DELETE")
4851
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
4952
}
5053
}
5154

52-
// handleGet opens a long-lived SSE stream for server-initiated events.
53-
// The MCP Streamable HTTP spec optionally allows clients to open a GET
54-
// connection for server-to-client events. Sluice does not currently
55-
// produce unsolicited events, so this handler keeps the stream open
56-
// with periodic SSE comments as keepalives until the client closes it
57-
// or the request context is cancelled.
58-
//
59-
// Without this, clients that probe with GET (e.g. openclaw's bundle-mcp
60-
// SSE transport) see HTTP 405 and log warnings on every probe.
61-
func (h *MCPHTTPHandler) handleGet(w http.ResponseWriter, r *http.Request) {
62-
w.Header().Set("Content-Type", "text/event-stream")
63-
w.Header().Set("Cache-Control", "no-cache")
64-
w.Header().Set("Connection", "keep-alive")
65-
w.WriteHeader(http.StatusOK)
66-
flusher, ok := w.(http.Flusher)
67-
if !ok {
68-
return
69-
}
70-
// Initial keepalive comment so the client sees the stream open.
71-
_, _ = w.Write([]byte(": sluice mcp sse\n\n"))
72-
flusher.Flush()
73-
74-
ticker := time.NewTicker(30 * time.Second)
75-
defer ticker.Stop()
76-
for {
77-
select {
78-
case <-r.Context().Done():
79-
return
80-
case <-ticker.C:
81-
if _, err := w.Write([]byte(": keepalive\n\n")); err != nil {
82-
return
83-
}
84-
flusher.Flush()
85-
}
86-
}
87-
}
88-
8955
func (h *MCPHTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) {
9056
ct := r.Header.Get("Content-Type")
9157
if ct != "" && !strings.HasPrefix(ct, "application/json") {

internal/mcp/server_http_test.go

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package mcp
22

33
import (
44
"bufio"
5-
"context"
65
"encoding/json"
76
"fmt"
87
"io"
@@ -290,8 +289,7 @@ func TestMCPHTTPNotificationReturns202(t *testing.T) {
290289
func TestMCPHTTPMethodNotAllowed(t *testing.T) {
291290
handler := newTestMCPHandler(t)
292291

293-
// PUT is not a valid MCP method; GET is handled as SSE stream.
294-
req := httptest.NewRequest(http.MethodPut, "/mcp", nil)
292+
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
295293
rec := httptest.NewRecorder()
296294
handler.ServeHTTP(rec, req)
297295

@@ -300,38 +298,8 @@ func TestMCPHTTPMethodNotAllowed(t *testing.T) {
300298
}
301299

302300
allow := rec.Header().Get("Allow")
303-
if !strings.Contains(allow, "POST") || !strings.Contains(allow, "GET") || !strings.Contains(allow, "DELETE") {
304-
t.Errorf("expected Allow header with POST, GET, and DELETE, got %q", allow)
305-
}
306-
}
307-
308-
func TestMCPHTTPGetOpensSSEStream(t *testing.T) {
309-
handler := newTestMCPHandler(t)
310-
311-
// GET should open an SSE stream and return immediately when the
312-
// request context is cancelled.
313-
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
314-
defer cancel()
315-
req := httptest.NewRequest(http.MethodGet, "/mcp", nil).WithContext(ctx)
316-
rec := httptest.NewRecorder()
317-
done := make(chan struct{})
318-
go func() {
319-
handler.ServeHTTP(rec, req)
320-
close(done)
321-
}()
322-
select {
323-
case <-done:
324-
case <-time.After(3 * time.Second):
325-
t.Fatal("handler did not return after context cancel")
326-
}
327-
if rec.Code != http.StatusOK {
328-
t.Errorf("expected 200, got %d", rec.Code)
329-
}
330-
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
331-
t.Errorf("expected text/event-stream, got %q", ct)
332-
}
333-
if !strings.Contains(rec.Body.String(), "sluice mcp sse") {
334-
t.Errorf("expected initial SSE comment, got %q", rec.Body.String())
301+
if !strings.Contains(allow, "POST") || !strings.Contains(allow, "DELETE") {
302+
t.Errorf("expected Allow header with POST and DELETE, got %q", allow)
335303
}
336304
}
337305

scripts/openclaw-gateway-rpc.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,23 @@ req.on("upgrade", (_res, socket) => {
319319
clearTimeout(deadline);
320320
fail("config.get response missing hash");
321321
}
322-
// Step 2 of wire-mcp: merge-patch mcp.servers.<name> = {url}.
322+
// Step 2 of wire-mcp: merge-patch mcp.servers.<name>. We must
323+
// set transport: "streamable-http" explicitly because openclaw's
324+
// pi-bundle-mcp-runtime defaults to the legacy SSE transport
325+
// when the field is omitted, and the SSE transport requires a
326+
// long-lived GET stream that sluice's gateway does not expose.
323327
// restartDelayMs gives us time to receive the response and exit
324328
// cleanly before the gateway restarts and kills our docker exec
325329
// (which would otherwise result in exit code 137).
326330
const raw = JSON.stringify({
327-
mcp: { servers: { [wireMcpName]: { url: wireMcpURL } } },
331+
mcp: {
332+
servers: {
333+
[wireMcpName]: {
334+
transport: "streamable-http",
335+
url: wireMcpURL,
336+
},
337+
},
338+
},
328339
});
329340
step = "sent-method";
330341
socket.write(

0 commit comments

Comments
 (0)