package main import ( "log/slog" "net" "net/http" "net/url" "os" "strconv" "strings" "time" "unicode" portico "github.com/josemirmoura/mcp-vps-agent-gateway" "github.com/josemirmoura/mcp-vps-agent-gateway/internal/gateway" "github.com/josemirmoura/mcp-vps-agent-gateway/internal/ipc" "github.com/josemirmoura/mcp-vps-agent-gateway/internal/securefs" ) func main() { slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) listen := getenv("VPS_AGENT_LISTEN", "127.0.0.1:8080") socket := os.Getenv("VPS_AGENT_BROKER_SOCKET") pocRoot := getenv("VPS_AGENT_POC_ROOT", "/tmp/vps-agent-poc") var exec gateway.Executor if socket == "" { if err := os.MkdirAll(pocRoot, 0o700); err != nil { slog.Error("fatal", "error", err); os.Exit(1) } fs, err := securefs.New([]string{pocRoot}, []string{pocRoot}, securefs.DefaultMaxBytes) if err != nil { slog.Error("fatal", "error", err); os.Exit(1) } exec = gateway.LocalExecutor{FS: fs} slog.Info("gateway_local_mode", "safe_root", pocRoot) } else { exec = gateway.BrokerExecutor{Client: ipc.Client{Socket: socket, Timeout: 15 * time.Second}} slog.Info("gateway_broker_configured", "socket", socket) } issuer := strings.TrimRight(os.Getenv("VPS_AGENT_OIDC_ISSUER"), "/") resource := os.Getenv("VPS_AGENT_OAUTH_RESOURCE") if resource == "" { resource = os.Getenv("VPS_AGENT_PUBLIC_URL") } metadataURL := os.Getenv("VPS_AGENT_RESOURCE_METADATA_URL") if metadataURL == "" && resource != "" { metadataURL = metadataURLForResource(resource) } authCfg := gateway.AuthConfig{ Mode: getenv("VPS_AGENT_AUTH_MODE", "none"), StaticToken: os.Getenv("VPS_AGENT_STATIC_TOKEN"), StaticSubject: getenv("VPS_AGENT_STATIC_SUBJECT", "local-dev"), RequiredScopes: splitScopes(os.Getenv("VPS_AGENT_REQUIRED_SCOPES")), ResourceMetadataURL: metadataURL, ResourceIdentifier: resource, InstanceID: os.Getenv("VPS_AGENT_INSTANCE_ID"), InstanceName: getenv("VPS_AGENT_INSTANCE_NAME", "vps-agent"), MaxConcurrentRequests: getenvInt("VPS_AGENT_MAX_CONCURRENT_REQUESTS", 64), ToolCallsPerSecond: getenvInt("VPS_AGENT_TOOL_CALLS_PER_SECOND", 20), ToolCallBurst: getenvInt("VPS_AGENT_TOOL_CALL_BURST", 60), } if issuer != "" { authCfg.AuthorizationServers = []string{issuer} } if (authCfg.Mode == "" || authCfg.Mode == "none") && !listenIsLoopback(listen) { slog.Error("unauthenticated_remote_bind_rejected", "listen", listen, "reason", "auth mode none is allowed only on loopback") os.Exit(1) } if os.Getenv("VPS_AGENT_PUBLIC_URL") != "" && authCfg.Mode != "integrated" { slog.Error("public_auth_rejected", "reason", "public MCP configuration requires integrated auth"); os.Exit(1) } if authCfg.Mode == "integrated" { if resource == "" || issuer == "" { slog.Error("integrated_auth_config_invalid", "reason", "OAuth resource/public URL and issuer required"); os.Exit(1) } introspectionURL := os.Getenv("VPS_AGENT_INTEGRATED_INTROSPECTION_URL") // The released Gateway must never use a plaintext introspection // endpoint. Internal numeric-loopback HTTP is permitted only by // disposable unit tests that construct NewIntegratedOIDCVerifier directly. introspectionTarget, parseErr := url.Parse(introspectionURL) if parseErr != nil || introspectionTarget == nil || introspectionTarget.Scheme != "https" { slog.Error("integrated_auth_config_invalid", "reason", "HTTPS introspection URL required for distributed Gateway") os.Exit(1) } introspectionHost := os.Getenv("VPS_AGENT_INTEGRATED_INTROSPECTION_HOST") introspectionClientID := os.Getenv("VPS_AGENT_INTEGRATED_INTROSPECTION_CLIENT_ID") introspectionClientSecret := os.Getenv("VPS_AGENT_INTEGRATED_INTROSPECTION_CLIENT_SECRET") expectedAudience := os.Getenv("VPS_AGENT_INTEGRATED_AUDIENCE_PROJECT_ID") verifier, err := gateway.NewIntegratedOIDCVerifier( introspectionURL, introspectionHost, introspectionClientID, introspectionClientSecret, issuer, expectedAudience, ) if err != nil { slog.Error("integrated_auth_config_failed", "error", err); os.Exit(1) } authCfg.Verifier = verifier } handler := gateway.Handler(exec, authCfg) srv := &http.Server{ Addr: listen, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 0, // Streamable HTTP may legitimately outlive a normal request timeout. IdleTimeout: 2 * time.Minute, } slog.Info("gateway_start", "product_version", portico.Version(), "listen", listen, "auth_mode", authCfg.Mode, "instance_id", authCfg.InstanceID, "instance_name", authCfg.InstanceName) if err := srv.ListenAndServe(); err != nil { slog.Error("gateway_exit", "error", err); os.Exit(1) } } func metadataURLForResource(raw string) string { u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return "" } return u.Scheme + "://" + u.Host + "/.well-known/oauth-protected-resource" } func listenIsLoopback(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { return false } if strings.EqualFold(host, "localhost") { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func splitScopes(raw string) []string { return strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) } func getenvInt(name string, fallback int) int { v := os.Getenv(name) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil || n <= 0 { return fallback } return n } func getenv(name, fallback string) string { if v := os.Getenv(name); v != "" { return v } return fallback }