package server import ( "fmt" "net/http" "os" "strconv" "strings" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/edgebill" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" "github.com/modelcontextprotocol/go-sdk/mcp" ) // Options configures the MCP HTTP server. type Options struct { Ledger *ledger.FileLedger Global *veraetime.Client Keeper *veraetime.Keeper Edge *edgebill.Client AuthPath string GlobalURL string RequireBearer bool JSONResponse bool Stateless bool // Public (DEC-654): writes need the caller's own credential; anonymous verify/hash_report only, rate limited. Public bool } // Server wraps the MCP Streamable HTTP handler mounted at /mcp. type Server struct { mcp *mcp.Server handler http.Handler deps *tools.Deps } // New builds an MCP server and Streamable HTTP handler. func New(opts Options) (*Server, error) { globalURL := strings.TrimSpace(opts.GlobalURL) if globalURL == "" { globalURL = veraetime.DefaultBaseURL } authPath := strings.TrimSpace(opts.AuthPath) if authPath == "" { authPath = veraetime.DefaultAuthPath() } global := opts.Global if global == nil { global = veraetime.NewClient(globalURL, veraetime.WithAuthPath(authPath)) } keeper := opts.Keeper if keeper == nil { keeper = veraetime.NewKeeper(global, authPath) } led := opts.Ledger if led == nil { var err error led, err = ledger.Open(ledger.DefaultPath()) if err != nil { return nil, fmt.Errorf("open ledger: %w", err) } } edge := opts.Edge if edge == nil { edge = edgebill.FromEnv() } deps := &tools.Deps{ Ledger: led, Global: global, Keeper: keeper, Edge: edge, RequireBearer: opts.RequireBearer, } if opts.Public { deps.Public, deps.Checker = true, global } mcpServer := tools.NewServer(deps) // Behind Caddy (or any reverse proxy) the bind is loopback while Host is // the public name (mcp.verae.ai). Disable the SDK's localhost Host check. stream := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpServer }, &mcp.StreamableHTTPOptions{ JSONResponse: opts.JSONResponse, Stateless: opts.Stateless, DisableLocalhostProtection: true, }) mux := http.NewServeMux() mux.Handle("/mcp", stream) mux.Handle("/mcp/", stream) return &Server{mcp: mcpServer, handler: mux, deps: deps}, nil } // Handler returns the HTTP handler (routes /mcp). func (s *Server) Handler() http.Handler { if s == nil { return http.NotFoundHandler() } return s.handler } // MCP returns the underlying MCP server. func (s *Server) MCP() *mcp.Server { if s == nil { return nil } return s.mcp } // Config is runtime configuration from flags and env. type Config struct { Transport string Host string Port int RequireBearer bool GlobalURL string AuthPath string LedgerPath string NATSListen string CORS bool } // LoadConfig merges flag defaults with environment overrides. // Env wins when set: VERAE_MCP_TRANSPORT, VERAE_MCP_HOST, VERAE_MCP_PORT, // VERAE_MCP_REQUIRE_BEARER, VERAE_GLOBAL_URL, VERAE_AUTH_PROPERTIES, VERAE_LEDGER_PATH, // VERAE_NATS_LISTEN, VERAE_MCP_CORS. // VERAE_NATS_LISTEN defaults to 127.0.0.1:4223; set empty to disable embedded NATS. func LoadConfig(transport, host string, port int) Config { cfg := Config{ Transport: transport, Host: host, Port: port, GlobalURL: veraetime.DefaultBaseURL, AuthPath: veraetime.DefaultAuthPath(), LedgerPath: ledger.DefaultPath(), NATSListen: "127.0.0.1:4223", } if v := strings.TrimSpace(os.Getenv("VERAE_MCP_TRANSPORT")); v != "" { cfg.Transport = v } if v := strings.TrimSpace(os.Getenv("VERAE_MCP_HOST")); v != "" { cfg.Host = v } if v := strings.TrimSpace(os.Getenv("VERAE_MCP_PORT")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.Port = n } } if v := strings.TrimSpace(os.Getenv("VERAE_GLOBAL_URL")); v != "" { cfg.GlobalURL = v } if v := strings.TrimSpace(os.Getenv("VERAE_AUTH_PROPERTIES")); v != "" { cfg.AuthPath = v } if v := strings.TrimSpace(os.Getenv("VERAE_LEDGER_PATH")); v != "" { cfg.LedgerPath = v } if _, set := os.LookupEnv("VERAE_NATS_LISTEN"); set { cfg.NATSListen = strings.TrimSpace(os.Getenv("VERAE_NATS_LISTEN")) } cfg.RequireBearer = envTruthy("VERAE_MCP_REQUIRE_BEARER") cfg.CORS = envTruthy("VERAE_MCP_CORS") if cfg.Transport == "" { cfg.Transport = "streamable-http" } if cfg.Host == "" { cfg.Host = "127.0.0.1" } if cfg.Port == 0 { cfg.Port = 8000 } return cfg } func envTruthy(key string) bool { v := strings.TrimSpace(os.Getenv(key)) switch strings.ToLower(v) { case "1", "true", "yes", "on": return true default: return false } } // Addr returns host:port. func (c Config) Addr() string { return fmt.Sprintf("%s:%d", c.Host, c.Port) } // WithCORS wraps h with permissive CORS when enabled (Python VERAE_MCP_CORS=1). func WithCORS(h http.Handler, enabled bool) http.Handler { if !enabled { return h } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") w.Header().Set("Access-Control-Expose-Headers", "mcp-session-id, Mcp-Session-Id") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } h.ServeHTTP(w, r) }) }