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:
parent
a4f86b5de6
commit
fe66067b54
2 changed files with 105 additions and 4 deletions
|
|
@ -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
96
pkg/mcp/transport_http.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue