server: add HTTP address flag

Amolith created

Change summary

cmd/cooked-mcp/main.go            | 41 +++++++++++++-
cmd/cooked-mcp/main_test.go       | 15 +++++
internal/appconfig/config.go      | 15 +++++
internal/appconfig/config_test.go | 85 +++++++++++++++++++++++++++-----
4 files changed, 135 insertions(+), 21 deletions(-)

Detailed changes

cmd/cooked-mcp/main.go 🔗

@@ -22,6 +22,12 @@ import (
 
 var version = "dev"
 
+type runOptions struct {
+	configPath string
+	transport  string
+	httpAddr   string
+}
+
 func init() {
 	buildVersion := builtVersion()
 	if buildVersion != "" {
@@ -31,24 +37,34 @@ func init() {
 
 func main() {
 	if err := run(); err != nil {
+		if errors.Is(err, flag.ErrHelp) {
+			os.Exit(0)
+		}
+
 		fmt.Fprintf(os.Stderr, "cooked-mcp: %v\n", err)
 		os.Exit(1)
 	}
 }
 
 func run() error {
-	configPath := flag.String("config", "", "path to TOML configuration file")
-	transport := flag.String("transport", "stdio", "MCP transport: stdio")
-	flag.Parse()
+	options, err := parseRunOptions(os.Args[1:])
+	if err != nil {
+		return err
+	}
 
-	if err := validateTransport(*transport); err != nil {
+	if err := validateTransport(options.transport); err != nil {
 		return err
 	}
 
 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer stop()
 
-	config, err := appconfig.Load(ctx, *configPath, configvalue.NewResolver())
+	config, err := appconfig.Load(
+		ctx,
+		options.configPath,
+		configvalue.NewResolver(),
+		appconfig.LoadOptions{HTTPAddr: options.httpAddr},
+	)
 	if err != nil {
 		return err
 	}
@@ -69,6 +85,21 @@ func run() error {
 	return nil
 }
 
+func parseRunOptions(args []string) (runOptions, error) {
+	flagSet := flag.NewFlagSet("cooked-mcp", flag.ContinueOnError)
+
+	var options runOptions
+	flagSet.StringVar(&options.configPath, "config", "", "path to TOML configuration file")
+	flagSet.StringVar(&options.transport, "transport", "stdio", "MCP transport: stdio")
+	flagSet.StringVar(&options.httpAddr, "http-addr", "", "MCP HTTP listen address")
+
+	if err := flagSet.Parse(args); err != nil {
+		return runOptions{}, err
+	}
+
+	return options, nil
+}
+
 func validateTransport(transport string) error {
 	if transport == "stdio" {
 		return nil

cmd/cooked-mcp/main_test.go 🔗

@@ -9,6 +9,21 @@ import (
 	"testing"
 )
 
+// server.CONFIG.8
+func TestParseRunOptionsACIDServerConfig8ParsesHTTPAddr(t *testing.T) {
+	got, err := parseRunOptions([]string{"--http-addr", "127.0.0.1:9999"})
+	if err != nil {
+		t.Fatalf("parseRunOptions() error = %v", err)
+	}
+
+	if got.httpAddr != "127.0.0.1:9999" {
+		t.Fatalf("httpAddr = %q, want CLI address", got.httpAddr)
+	}
+	if got.transport != "stdio" {
+		t.Fatalf("transport = %q, want stdio", got.transport)
+	}
+}
+
 // server.TRANSPORT.4
 func TestValidateTransportACIDServerTransport4AcceptsStdio(t *testing.T) {
 	if err := validateTransport("stdio"); err != nil {

internal/appconfig/config.go 🔗

@@ -60,8 +60,18 @@ type Config struct {
 	HTTPToken string
 }
 
+// LoadOptions contains explicit configuration overrides.
+type LoadOptions struct {
+	HTTPAddr string
+}
+
 // Load reads and resolves configuration. Missing default config files are ignored.
-func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Config, error) {
+func Load(
+	ctx context.Context,
+	path string,
+	resolver *configvalue.Resolver,
+	options LoadOptions,
+) (Config, error) {
 	fileConfig, err := loadFile(path)
 	if err != nil {
 		return Config{}, err
@@ -100,6 +110,9 @@ func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Con
 	if envHTTPAddr := os.Getenv("COOKED_MCP_HTTP_ADDR"); envHTTPAddr != "" {
 		httpAddr = envHTTPAddr
 	}
+	if options.HTTPAddr != "" {
+		httpAddr = options.HTTPAddr
+	}
 	httpToken, err := httpToken(ctx, resolver, fileConfig.MCP.HTTPToken)
 	if err != nil {
 		return Config{}, err

internal/appconfig/config_test.go 🔗

@@ -23,7 +23,7 @@ name = "$COOKED_USERNAME_FROM_MY_SHELL"
 password = "!printf shell-password"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -46,7 +46,7 @@ name = "toml-user"
 password = "toml-password"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -66,7 +66,7 @@ name = "toml-user"
 password = "toml-password"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -87,7 +87,7 @@ password = "toml-password"
 http_addr = "127.0.0.1:9000"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -110,7 +110,7 @@ password = "toml-password"
 http_addr = "127.0.0.1:9000"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -120,6 +120,34 @@ http_addr = "127.0.0.1:9000"
 	}
 }
 
+// server.CONFIG.8
+func TestLoadACIDServerConfig8HTTPAddrOverridesEnvironmentAndTOML(t *testing.T) {
+	t.Setenv("COOKED_MCP_HTTP_ADDR", "127.0.0.1:9001")
+
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+
+[mcp]
+http_addr = "127.0.0.1:9000"
+`)
+
+	got, err := Load(
+		context.Background(),
+		configPath,
+		configvalue.NewResolver(),
+		LoadOptions{HTTPAddr: "127.0.0.1:9002"},
+	)
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+
+	if got.HTTPAddr != "127.0.0.1:9002" {
+		t.Fatalf("HTTPAddr = %q, want explicit address", got.HTTPAddr)
+	}
+}
+
 // server.SECURITY.4 server.SECURITY.9
 func TestLoadACIDServerSecurity4And9EnvironmentOverridesTOMLHTTPToken(t *testing.T) {
 	t.Setenv("COOKED_MCP_HTTP_TOKEN", "env-token")
@@ -133,7 +161,7 @@ password = "toml-password"
 http_token = "toml-token"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -156,7 +184,7 @@ password = "toml-password"
 http_token = "$TEST_HTTP_TOKEN"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -176,7 +204,7 @@ name = "toml-user"
 password = "toml-password"
 `)
 
-	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err == nil {
 		t.Fatal("Load() error = nil, want empty HTTP token error")
 	}
@@ -196,7 +224,7 @@ password = "toml-password"
 http_token = ""
 `)
 
-	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err == nil {
 		t.Fatal("Load() error = nil, want empty HTTP token error")
 	}
@@ -227,7 +255,7 @@ password = "toml-password"
 http_addr = "`+tt.addr+`"
 `)
 
-			got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+			got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 			if err != nil {
 				t.Fatalf("Load() error = %v", err)
 			}
@@ -252,7 +280,7 @@ password = "toml-password"
 http_addr = "0.0.0.0:8123"
 `)
 
-	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err == nil {
 		t.Fatal("Load() error = nil, want non-loopback HTTP token error")
 	}
@@ -273,7 +301,7 @@ http_addr = "0.0.0.0:8123"
 http_token = "secret-token"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err != nil {
 		t.Fatalf("Load() error = %v", err)
 	}
@@ -285,8 +313,35 @@ http_token = "secret-token"
 	}
 }
 
+// server.SECURITY.1
+func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrOverrideWithoutToken(t *testing.T) {
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+`)
+
+	_, err := Load(
+		context.Background(),
+		configPath,
+		configvalue.NewResolver(),
+		LoadOptions{HTTPAddr: "0.0.0.0:8123"},
+	)
+	if err == nil {
+		t.Fatal("Load() error = nil, want non-loopback HTTP token error")
+	}
+	if !strings.Contains(err.Error(), "MCP HTTP token is required") {
+		t.Fatalf("Load() error = %v, want HTTP token required error", err)
+	}
+}
+
 func TestLoadRejectsMissingExplicitConfigFile(t *testing.T) {
-	_, err := Load(context.Background(), filepath.Join(t.TempDir(), "missing.toml"), configvalue.NewResolver())
+	_, err := Load(
+		context.Background(),
+		filepath.Join(t.TempDir(), "missing.toml"),
+		configvalue.NewResolver(),
+		LoadOptions{},
+	)
 	if err == nil {
 		t.Fatal("Load() error = nil, want missing config error")
 	}
@@ -303,7 +358,7 @@ func TestLoadACIDServerConfig72RejectsMissingDefaultConfigHome(t *testing.T) {
 		userHomeDir = originalUserHomeDir
 	})
 
-	_, err := Load(context.Background(), "", configvalue.NewResolver())
+	_, err := Load(context.Background(), "", configvalue.NewResolver(), LoadOptions{})
 	if err == nil {
 		t.Fatal("Load() error = nil, want default config path error")
 	}
@@ -322,7 +377,7 @@ password = "toml-password"
 base_url = "http://example.com"
 `)
 
-	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
 	if err == nil {
 		t.Fatal("Load() error = nil, want non-local HTTP base URL error")
 	}