config: allow unauthenticated HTTP listeners

Amolith created

Remove the configuration-time requirement for MCP HTTP bearer authentication on non-loopback listen addresses. This lets users bind Streamable HTTP to Tailscale, LAN, all-interface, or hostname-like addresses without also configuring a token.

Keep bearer-token authentication available when configured, and cover the relaxed address handling with server.SECURITY.1-1 tests.

Change summary

features/cooked-mcp/server.feature.yaml |  5 +
internal/appconfig/config.go            | 18 ------
internal/appconfig/config_test.go       | 80 ++++++++++++--------------
3 files changed, 42 insertions(+), 61 deletions(-)

Detailed changes

features/cooked-mcp/server.feature.yaml 🔗

@@ -41,7 +41,10 @@ constraints:
       3: Multi-account routing, per-client users, and per-request Cooked credentials are out of scope.
   SECURITY:
     requirements:
-      1: HTTP transport refuses non-loopback listeners unless MCP HTTP authentication is configured.
+      1:
+        requirement: HTTP transport refuses non-loopback listeners unless MCP HTTP authentication is configured.
+        deprecated: true
+      1-1: HTTP transport accepts any configured listen address without requiring MCP HTTP authentication.
       2: HTTP authentication secrets are never logged.
       3: HTTP transport supports bearer-token authentication.
       4: HTTP bearer tokens can be configured through COOKED_MCP_HTTP_TOKEN.

internal/appconfig/config.go 🔗

@@ -117,9 +117,6 @@ func Load(
 	if err != nil {
 		return Config{}, err
 	}
-	if err := validateHTTPListenerSafety(httpAddr, httpToken); err != nil {
-		return Config{}, err
-	}
 
 	return Config{
 		Username:  username,
@@ -242,21 +239,6 @@ func httpToken(ctx context.Context, resolver *configvalue.Resolver, config *stri
 	return value, nil
 }
 
-func validateHTTPListenerSafety(addr, token string) error {
-	host, _, err := net.SplitHostPort(addr)
-	if err != nil {
-		return fmt.Errorf("invalid MCP HTTP listen address: %w", err)
-	}
-	if isLoopbackHost(host) {
-		return nil
-	}
-	if token != "" {
-		return nil
-	}
-
-	return fmt.Errorf("MCP HTTP token is required for non-loopback HTTP listen addresses")
-}
-
 func isLoopbackHost(host string) bool {
 	if strings.EqualFold(host, "localhost") {
 		return true

internal/appconfig/config_test.go 🔗

@@ -233,8 +233,8 @@ http_token = ""
 	}
 }
 
-// server.SECURITY.1
-func TestLoadACIDServerSecurity1AllowsLoopbackHTTPAddrWithoutToken(t *testing.T) {
+// server.SECURITY.1-1
+func TestLoadACIDServerSecurity1_1AllowsLoopbackHTTPAddrWithoutToken(t *testing.T) {
 	tests := []struct {
 		name string
 		addr string
@@ -269,69 +269,65 @@ http_addr = "`+tt.addr+`"
 	}
 }
 
-// server.SECURITY.1
-func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrWithoutToken(t *testing.T) {
-	configPath := filepath.Join(t.TempDir(), "config.toml")
-	writeConfig(t, configPath, `[user]
-name = "toml-user"
-password = "toml-password"
-
-[mcp]
-http_addr = "0.0.0.0:8123"
-`)
-
-	_, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
-	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)
+// server.SECURITY.1-1
+func TestLoadACIDServerSecurity1_1AllowsHTTPAddrWithoutToken(t *testing.T) {
+	tests := []struct {
+		name string
+		addr string
+	}{
+		{name: "all interfaces", addr: "0.0.0.0:8123"},
+		{name: "LAN", addr: "192.168.1.10:8123"},
+		{name: "Tailscale", addr: "100.64.0.1:8123"},
+		{name: "hostname-like LAN", addr: "192.168.1.blah:8123"},
 	}
-}
 
-// server.SECURITY.1
-func TestLoadACIDServerSecurity1AllowsNonLoopbackHTTPAddrWithToken(t *testing.T) {
-	configPath := filepath.Join(t.TempDir(), "config.toml")
-	writeConfig(t, configPath, `[user]
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			configPath := filepath.Join(t.TempDir(), "config.toml")
+			writeConfig(t, configPath, `[user]
 name = "toml-user"
 password = "toml-password"
 
 [mcp]
-http_addr = "0.0.0.0:8123"
-http_token = "secret-token"
+http_addr = "`+tt.addr+`"
 `)
 
-	got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
-	if err != nil {
-		t.Fatalf("Load() error = %v", err)
-	}
-	if got.HTTPAddr != "0.0.0.0:8123" {
-		t.Fatalf("HTTPAddr = %q, want non-loopback address", got.HTTPAddr)
-	}
-	if got.HTTPToken != "secret-token" {
-		t.Fatal("HTTPToken was not resolved")
+			got, err := Load(context.Background(), configPath, configvalue.NewResolver(), LoadOptions{})
+			if err != nil {
+				t.Fatalf("Load() error = %v", err)
+			}
+			if got.HTTPAddr != tt.addr {
+				t.Fatalf("HTTPAddr = %q, want %q", got.HTTPAddr, tt.addr)
+			}
+			if got.HTTPToken != "" {
+				t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
+			}
+		})
 	}
 }
 
-// server.SECURITY.1
-func TestLoadACIDServerSecurity1RejectsNonLoopbackHTTPAddrOverrideWithoutToken(t *testing.T) {
+// server.SECURITY.1-1
+func TestLoadACIDServerSecurity1_1AllowsHTTPAddrOverrideWithoutToken(t *testing.T) {
 	configPath := filepath.Join(t.TempDir(), "config.toml")
 	writeConfig(t, configPath, `[user]
 name = "toml-user"
 password = "toml-password"
 `)
 
-	_, err := Load(
+	got, 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 err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+	if got.HTTPAddr != "0.0.0.0:8123" {
+		t.Fatalf("HTTPAddr = %q, want override", got.HTTPAddr)
 	}
-	if !strings.Contains(err.Error(), "MCP HTTP token is required") {
-		t.Fatalf("Load() error = %v, want HTTP token required error", err)
+	if got.HTTPToken != "" {
+		t.Fatalf("HTTPToken = %q, want empty", got.HTTPToken)
 	}
 }