feat: add Streamable HTTP transport with Bearer token auth

New transport_http.go adds ServeHTTP() using the go-sdk's built-in
StreamableHTTPHandler. Supports:
- SSE streaming (GET /mcp) for server-to-client notifications
- JSON-RPC (POST /mcp) for tool calls
- Session management with 30min idle timeout
- Bearer token auth via MCP_AUTH_TOKEN env var
- Health check at /health (no auth)

Transport selection via env vars:
- MCP_HTTP_ADDR → Streamable HTTP
- MCP_ADDR → TCP
- (default) → Stdio

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-17 04:25:28 +00:00
parent a4f86b5de6
commit fe66067b54
2 changed files with 105 additions and 4 deletions

View file

@ -562,16 +562,21 @@ func detectLanguageFromPath(path string) string {
}
// Run starts the MCP server.
// If MCP_ADDR is set, it starts a TCP server.
// Otherwise, it starts a Stdio server.
// Transport selection:
// - MCP_HTTP_ADDR set → Streamable HTTP (with optional MCP_AUTH_TOKEN)
// - MCP_ADDR set → TCP
// - Otherwise → Stdio
func (s *Service) Run(ctx context.Context) error {
addr := os.Getenv("MCP_ADDR")
if addr != "" {
if httpAddr := os.Getenv("MCP_HTTP_ADDR"); httpAddr != "" {
return s.ServeHTTP(ctx, httpAddr)
}
if addr := os.Getenv("MCP_ADDR"); addr != "" {
return s.ServeTCP(ctx, addr)
}
return s.server.Run(ctx, &mcp.StdioTransport{})
}
// Server returns the underlying MCP server for advanced configuration.
func (s *Service) Server() *mcp.Server {
return s.server

96
pkg/mcp/transport_http.go Normal file
View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"crypto/subtle"
"net"
"net/http"
"os"
"time"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// DefaultHTTPAddr is the default address for the MCP HTTP server.
const DefaultHTTPAddr = "127.0.0.1:9101"
// ServeHTTP starts the MCP server with Streamable HTTP transport.
// Supports Bearer token authentication via MCP_AUTH_TOKEN env var.
// If no token is set, authentication is disabled (local development mode).
//
// The server exposes a single endpoint at /mcp that handles:
// - GET: Open SSE stream for server-to-client notifications
// - POST: Send JSON-RPC messages (tool calls, etc.)
// - DELETE: Terminate session
func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
if addr == "" {
addr = DefaultHTTPAddr
}
authToken := os.Getenv("MCP_AUTH_TOKEN")
handler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server {
return s.server
},
&mcp.StreamableHTTPOptions{
SessionTimeout: 30 * time.Minute,
},
)
mux := http.NewServeMux()
mux.Handle("/mcp", withAuth(authToken, handler))
// Health check (no auth)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
listener, err := net.Listen("tcp", addr)
if err != nil {
return coreerr.E("mcp.ServeHTTP", "failed to listen on "+addr, err)
}
defer listener.Close()
diagPrintf("MCP HTTP server listening on %s\n", addr)
server := &http.Server{Handler: mux}
// Graceful shutdown on context cancellation
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
}()
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return coreerr.E("mcp.ServeHTTP", "server error", err)
}
return nil
}
// withAuth wraps an http.Handler with Bearer token authentication.
// If token is empty, authentication is disabled (passthrough).
func withAuth(token string, next http.Handler) http.Handler {
if token == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if len(auth) < 7 || auth[:7] != "Bearer " {
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
return
}
provided := auth[7:]
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}