Build and publish / Test and build (windows) (push) Failing after 11s
Build and publish / Test and build (darwin) (push) Successful in 1m32s
Build and publish / Test and build (linux) (push) Canceled after 0s
Build and publish / Build and publish Docker image (push) Canceled after 0s
Network MCP clients (e.g. browser-based frontends) can't spawn stdio subprocesses, so the server now optionally serves the 2024-11-05 MCP HTTP/SSE transport via MCP_TRANSPORT=sse and MCP_PORT (default 8080), backed by the SDK's SSEHandler behind permissive CORS. stdio remains the default and is behaviourally unchanged.
106 lines
2.7 KiB
Go
106 lines
2.7 KiB
Go
package transport
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func newTestServer() *mcp.Server {
|
|
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.0"}, nil)
|
|
}
|
|
|
|
func TestSSEEstablishesConnection(t *testing.T) {
|
|
ts := httptest.NewServer(NewSSEHandler(newTestServer()))
|
|
defer ts.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/sse", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /sse: status %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Fatalf("GET /sse: Content-Type %q", ct)
|
|
}
|
|
if acao := resp.Header.Get("Access-Control-Allow-Origin"); acao != "*" {
|
|
t.Fatalf("GET /sse: Access-Control-Allow-Origin %q", acao)
|
|
}
|
|
|
|
// The first SSE event must be 'endpoint' carrying the session POST URL.
|
|
sc := bufio.NewScanner(resp.Body)
|
|
var event, data string
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if strings.HasPrefix(line, "event:") {
|
|
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
|
} else if strings.HasPrefix(line, "data:") {
|
|
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
break
|
|
}
|
|
}
|
|
if event != "endpoint" {
|
|
t.Fatalf("first event = %q, want %q", event, "endpoint")
|
|
}
|
|
if !strings.Contains(data, "sessionid=") {
|
|
t.Fatalf("endpoint data %q lacks sessionid", data)
|
|
}
|
|
}
|
|
|
|
func TestSSECORSPreflight(t *testing.T) {
|
|
ts := httptest.NewServer(NewSSEHandler(newTestServer()))
|
|
defer ts.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodOptions, ts.URL+"/sse", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("OPTIONS /sse: status %d", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
|
|
t.Fatal("OPTIONS /sse: missing CORS headers")
|
|
}
|
|
}
|
|
|
|
func TestSSEPostMessagesRequiresSession(t *testing.T) {
|
|
ts := httptest.NewServer(NewSSEHandler(newTestServer()))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Post(ts.URL+"/messages", "application/json",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("POST /messages without sessionid: status %d", resp.StatusCode)
|
|
}
|
|
}
|