mcp: mount HTTP handler

Amolith created

Change summary

internal/mcp/http.go      | 18 ++++++++
internal/mcp/http_test.go | 90 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 108 insertions(+)

Detailed changes

internal/mcp/http.go 🔗

@@ -8,8 +8,26 @@ import (
 	"crypto/subtle"
 	"net/http"
 	"strings"
+
+	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
+// HTTPHandler returns the authenticated Streamable HTTP MCP handler.
+func (s *Server) HTTPHandler(token string) http.Handler {
+	streamable := sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server {
+		return s.sdk
+	}, nil)
+
+	return newHTTPHandler(token, streamable)
+}
+
+func newHTTPHandler(token string, mcpHandler http.Handler) http.Handler {
+	mux := http.NewServeMux()
+	mux.Handle("/mcp", requireBearerToken(token, mcpHandler))
+
+	return mux
+}
+
 func requireBearerToken(token string, next http.Handler) http.Handler {
 	if token == "" {
 		return next

internal/mcp/http_test.go 🔗

@@ -12,6 +12,96 @@ import (
 	"testing"
 )
 
+// server.TRANSPORT.5
+func TestHTTPHandlerACIDServerTransport5RoutesMCPPath(t *testing.T) {
+	calls := 0
+	handler := newHTTPHandler("", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		calls++
+		w.WriteHeader(http.StatusNoContent)
+	}))
+
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	handler.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusNoContent {
+		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
+	}
+	if calls != 1 {
+		t.Fatalf("MCP handler calls = %d, want 1", calls)
+	}
+}
+
+// server.TRANSPORT.5
+func TestHTTPHandlerACIDServerTransport5DoesNotRouteOtherPaths(t *testing.T) {
+	tests := []string{"/", "/mcp/extra"}
+
+	for _, path := range tests {
+		t.Run(path, func(t *testing.T) {
+			calls := 0
+			handler := newHTTPHandler("", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+				calls++
+			}))
+
+			recorder := httptest.NewRecorder()
+			request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, nil)
+			handler.ServeHTTP(recorder, request)
+
+			if recorder.Code != http.StatusNotFound {
+				t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNotFound)
+			}
+			if calls != 0 {
+				t.Fatalf("MCP handler calls = %d, want 0", calls)
+			}
+		})
+	}
+}
+
+// server.SECURITY.7
+func TestHTTPHandlerACIDServerSecurity7GatesMCPPathWithBearerToken(t *testing.T) {
+	calls := 0
+	handler := newHTTPHandler("secret-token", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		calls++
+		w.WriteHeader(http.StatusNoContent)
+	}))
+
+	unauthorized := httptest.NewRecorder()
+	unauthorizedRequest := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	handler.ServeHTTP(unauthorized, unauthorizedRequest)
+
+	if unauthorized.Code != http.StatusUnauthorized {
+		t.Fatalf("unauthorized status = %d, want %d", unauthorized.Code, http.StatusUnauthorized)
+	}
+	if calls != 0 {
+		t.Fatalf("MCP handler calls after unauthorized request = %d, want 0", calls)
+	}
+
+	authorized := httptest.NewRecorder()
+	authorizedRequest := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	authorizedRequest.Header.Set("Authorization", "Bearer secret-token")
+	handler.ServeHTTP(authorized, authorizedRequest)
+
+	if authorized.Code != http.StatusNoContent {
+		t.Fatalf("authorized status = %d, want %d", authorized.Code, http.StatusNoContent)
+	}
+	if calls != 1 {
+		t.Fatalf("MCP handler calls after authorized request = %d, want 1", calls)
+	}
+}
+
+// server.SECURITY.7
+func TestServerHTTPHandlerACIDServerSecurity7WrapsSDKHandlerWithBearerAuth(t *testing.T) {
+	handler := NewServer(nil, "test").HTTPHandler("secret-token")
+
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil)
+	handler.ServeHTTP(recorder, request)
+
+	if recorder.Code != http.StatusUnauthorized {
+		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
+	}
+}
+
 // server.SECURITY.3 server.SECURITY.6
 func TestHTTPBearerAuthACIDServerSecurity3And6AllowsMatchingBearerToken(t *testing.T) {
 	calls := 0