config: resolve MCP HTTP token

Amolith created

Change summary

internal/appconfig/config.go      | 40 +++++++++++++--
internal/appconfig/config_test.go | 85 +++++++++++++++++++++++++++++++++
2 files changed, 119 insertions(+), 6 deletions(-)

Detailed changes

internal/appconfig/config.go 🔗

@@ -46,8 +46,8 @@ type CookedConfig struct {
 
 // MCPConfig contains MCP transport configuration.
 type MCPConfig struct {
-	HTTPAddr  string `toml:"http_addr"`
-	HTTPToken string `toml:"http_token"`
+	HTTPAddr  string  `toml:"http_addr"`
+	HTTPToken *string `toml:"http_token"`
 }
 
 // Config is fully resolved runtime configuration.
@@ -99,12 +99,17 @@ func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Con
 	if envHTTPAddr := os.Getenv("COOKED_MCP_HTTP_ADDR"); envHTTPAddr != "" {
 		httpAddr = envHTTPAddr
 	}
+	httpToken, err := httpToken(ctx, resolver, fileConfig.MCP.HTTPToken)
+	if err != nil {
+		return Config{}, err
+	}
 
 	return Config{
-		Username: username,
-		Password: password,
-		BaseURL:  baseURL,
-		HTTPAddr: httpAddr,
+		Username:  username,
+		Password:  password,
+		BaseURL:   baseURL,
+		HTTPAddr:  httpAddr,
+		HTTPToken: httpToken,
 	}, nil
 }
 
@@ -197,6 +202,29 @@ func baseURL(ctx context.Context, resolver *configvalue.Resolver, config string)
 	return parsed, nil
 }
 
+func httpToken(ctx context.Context, resolver *configvalue.Resolver, config *string) (string, error) {
+	if value, ok := os.LookupEnv("COOKED_MCP_HTTP_TOKEN"); ok {
+		if value == "" {
+			return "", fmt.Errorf("MCP HTTP token must not be empty")
+		}
+
+		return value, nil
+	}
+	if config == nil {
+		return "", nil
+	}
+
+	value, err := resolver.ResolveOrThrow(ctx, *config, "MCP HTTP token")
+	if err != nil {
+		return "", err
+	}
+	if value == "" {
+		return "", fmt.Errorf("MCP HTTP token must not be empty")
+	}
+
+	return value, nil
+}
+
 func isLoopbackHost(host string) bool {
 	if host == "localhost" {
 		return true

internal/appconfig/config_test.go 🔗

@@ -120,6 +120,91 @@ http_addr = "127.0.0.1:9000"
 	}
 }
 
+// server.SECURITY.4 server.SECURITY.9
+func TestLoadACIDServerSecurity4And9EnvironmentOverridesTOMLHTTPToken(t *testing.T) {
+	t.Setenv("COOKED_MCP_HTTP_TOKEN", "env-token")
+
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+
+[mcp]
+http_token = "toml-token"
+`)
+
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+
+	if got.HTTPToken != "env-token" {
+		t.Fatalf("HTTPToken = %q, want environment token", got.HTTPToken)
+	}
+}
+
+// server.SECURITY.5 server.SECURITY.10 server.SECURITY.12 server.SECURITY.13
+func TestLoadACIDServerSecurity5And10ResolvesTOMLHTTPTokenConfigValue(t *testing.T) {
+	t.Setenv("TEST_HTTP_TOKEN", "shell-token")
+
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+
+[mcp]
+http_token = "$TEST_HTTP_TOKEN"
+`)
+
+	got, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+
+	if got.HTTPToken != "shell-token" {
+		t.Fatalf("HTTPToken = %q, want resolved TOML token", got.HTTPToken)
+	}
+}
+
+// server.SECURITY.11
+func TestLoadACIDServerSecurity11RejectsEmptyEnvironmentHTTPToken(t *testing.T) {
+	t.Setenv("COOKED_MCP_HTTP_TOKEN", "")
+
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+`)
+
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	if err == nil {
+		t.Fatal("Load() error = nil, want empty HTTP token error")
+	}
+	if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
+		t.Fatalf("Load() error = %v, want empty HTTP token error", err)
+	}
+}
+
+// server.SECURITY.11
+func TestLoadACIDServerSecurity11RejectsEmptyTOMLHTTPToken(t *testing.T) {
+	configPath := filepath.Join(t.TempDir(), "config.toml")
+	writeConfig(t, configPath, `[user]
+name = "toml-user"
+password = "toml-password"
+
+[mcp]
+http_token = ""
+`)
+
+	_, err := Load(context.Background(), configPath, configvalue.NewResolver())
+	if err == nil {
+		t.Fatal("Load() error = nil, want empty HTTP token error")
+	}
+	if !strings.Contains(err.Error(), "MCP HTTP token must not be empty") {
+		t.Fatalf("Load() error = %v, want empty HTTP token error", err)
+	}
+}
+
 func TestLoadRejectsMissingExplicitConfigFile(t *testing.T) {
 	_, err := Load(context.Background(), filepath.Join(t.TempDir(), "missing.toml"), configvalue.NewResolver())
 	if err == nil {