http.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. // Package transport wires the MCP server to stdio or streamable HTTP.
  2. package transport
  3. import (
  4. "context"
  5. "crypto/subtle"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "net"
  10. "net/http"
  11. "strings"
  12. "time"
  13. "github.com/modelcontextprotocol/go-sdk/mcp"
  14. )
  15. // DefaultSessionTimeout closes idle HTTP sessions after 30 minutes.
  16. const DefaultSessionTimeout = 30 * time.Minute
  17. // HTTPOptions configures the streamable HTTP transport.
  18. type HTTPOptions struct {
  19. Addr string
  20. Path string
  21. Stateless bool
  22. AuthToken string
  23. SessionTimeout time.Duration
  24. Logger *slog.Logger
  25. ProtocolLogger *slog.Logger
  26. NewServer func(*http.Request) *mcp.Server
  27. }
  28. // NewHTTPHandler returns an HTTP handler that serves the MCP endpoint at
  29. // opts.Path and a liveness endpoint at /healthz. If AuthToken is set, every
  30. // request except /healthz requires a matching bearer token.
  31. func NewHTTPHandler(opts HTTPOptions) http.Handler {
  32. if opts.NewServer == nil {
  33. panic("transport: NewHTTPHandler requires NewServer")
  34. }
  35. logger := opts.Logger
  36. if logger == nil {
  37. logger = slog.New(slog.DiscardHandler)
  38. }
  39. if opts.Path == "" {
  40. opts.Path = "/mcp"
  41. }
  42. if opts.SessionTimeout <= 0 {
  43. opts.SessionTimeout = DefaultSessionTimeout
  44. }
  45. protocolLogger := opts.ProtocolLogger
  46. if protocolLogger == nil {
  47. protocolLogger = logger
  48. }
  49. mcpHandler := mcp.NewStreamableHTTPHandler(opts.NewServer, &mcp.StreamableHTTPOptions{
  50. Stateless: opts.Stateless,
  51. Logger: protocolLogger,
  52. SessionTimeout: opts.SessionTimeout,
  53. })
  54. mux := http.NewServeMux()
  55. mux.Handle(opts.Path, mcpHandler)
  56. mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
  57. w.Header().Set("Content-Type", "application/json")
  58. w.Header().Set("Cache-Control", "no-store")
  59. _, _ = w.Write([]byte(`{"status":"ok"}`))
  60. })
  61. var handler http.Handler = mux
  62. if opts.AuthToken != "" {
  63. handler = bearerAuth(opts.AuthToken, handler)
  64. }
  65. return logRequests(logger, handler)
  66. }
  67. // ServeHTTP runs the streamable HTTP transport until ctx is cancelled.
  68. func ServeHTTP(ctx context.Context, opts HTTPOptions) error {
  69. logger := opts.Logger
  70. if logger == nil {
  71. logger = slog.New(slog.DiscardHandler)
  72. }
  73. ln, err := net.Listen("tcp", opts.Addr)
  74. if err != nil {
  75. return fmt.Errorf("listen on %s: %w", opts.Addr, err)
  76. }
  77. if opts.AuthToken == "" && !isLoopbackListener(ln.Addr()) {
  78. _ = ln.Close()
  79. return fmt.Errorf("refusing to serve HTTP on non-loopback address %s without MCP_AUTH_TOKEN", ln.Addr())
  80. }
  81. srv := &http.Server{
  82. Handler: NewHTTPHandler(opts),
  83. ReadHeaderTimeout: 10 * time.Second,
  84. IdleTimeout: 2 * time.Minute,
  85. }
  86. logger.Info("WikiJS MCP server listening",
  87. "transport", "http",
  88. "addr", ln.Addr().String(),
  89. "path", opts.Path,
  90. "stateless", opts.Stateless,
  91. "auth", opts.AuthToken != "",
  92. )
  93. errCh := make(chan error, 1)
  94. go func() {
  95. errCh <- srv.Serve(ln)
  96. }()
  97. select {
  98. case <-ctx.Done():
  99. shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  100. defer cancel()
  101. if err := srv.Shutdown(shutdownCtx); err != nil {
  102. logger.Warn("HTTP shutdown incomplete, forcing close", "error", err)
  103. _ = srv.Close()
  104. }
  105. return nil
  106. case err := <-errCh:
  107. if errors.Is(err, http.ErrServerClosed) {
  108. return nil
  109. }
  110. return err
  111. }
  112. }
  113. func isLoopbackListener(addr net.Addr) bool {
  114. tcp, ok := addr.(*net.TCPAddr)
  115. return ok && tcp.IP.IsLoopback()
  116. }
  117. func bearerAuth(token string, next http.Handler) http.Handler {
  118. expected := []byte(token)
  119. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  120. if r.URL.Path == "/healthz" {
  121. next.ServeHTTP(w, r)
  122. return
  123. }
  124. scheme, got, ok := strings.Cut(r.Header.Get("Authorization"), " ")
  125. if !ok || !strings.EqualFold(scheme, "Bearer") || subtle.ConstantTimeCompare([]byte(got), expected) != 1 {
  126. w.Header().Set("WWW-Authenticate", `Bearer realm="wikijs-mcp"`)
  127. http.Error(w, "unauthorized", http.StatusUnauthorized)
  128. return
  129. }
  130. next.ServeHTTP(w, r)
  131. })
  132. }
  133. type statusWriter struct {
  134. http.ResponseWriter
  135. status int
  136. }
  137. func (w *statusWriter) WriteHeader(code int) {
  138. if w.status == 0 {
  139. w.status = code
  140. }
  141. w.ResponseWriter.WriteHeader(code)
  142. }
  143. func (w *statusWriter) Write(b []byte) (int, error) {
  144. if w.status == 0 {
  145. w.status = http.StatusOK
  146. }
  147. return w.ResponseWriter.Write(b)
  148. }
  149. // Unwrap lets http.ResponseController reach the underlying writer, which is
  150. // required for HTTP streaming.
  151. func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
  152. func logRequests(logger *slog.Logger, next http.Handler) http.Handler {
  153. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  154. start := time.Now()
  155. sw := &statusWriter{ResponseWriter: w}
  156. next.ServeHTTP(sw, r)
  157. status := sw.status
  158. if status == 0 {
  159. status = http.StatusOK
  160. }
  161. logger.Debug("http request",
  162. "method", r.Method,
  163. "path", r.URL.Path,
  164. "status", status,
  165. "duration", time.Since(start),
  166. "remote", r.RemoteAddr,
  167. )
  168. })
  169. }