diff --git a/cmd/cooked-mcp/main.go b/cmd/cooked-mcp/main.go index e19310db4e9a95da8ef47c9c62c23c3416dd595d..bb2b3229a7dd1a0ce876021f10c82a0bb14aada0 100644 --- a/cmd/cooked-mcp/main.go +++ b/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 diff --git a/cmd/cooked-mcp/main_test.go b/cmd/cooked-mcp/main_test.go index 515d6953ba95b7197dc6509dc5ffd1a564464bc2..1983dac7068e6d796bd1aff2eaaa9dc706f8299f 100644 --- a/cmd/cooked-mcp/main_test.go +++ b/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 { diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 19d45c91411623dcac06236e4b3dd96f68d3401f..734e12bc5dcd20c30490f68a10941a1cbfffd271 100644 --- a/internal/appconfig/config.go +++ b/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 diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 0becaf80f27810b96ec6f633d186d06c7091c492..9d46e9d6004df9bd21eb35b07fbf61cc96cd6a3e 100644 --- a/internal/appconfig/config_test.go +++ b/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") }