tooling: add golangci-lint config

Amolith created

Change summary

.golangci.toml                      |  34 ++
internal/appconfig/config.go        |  22 +
internal/configvalue/configvalue.go | 120 ++++----
internal/cooked/client.go           |  15 
internal/cooked/client_test.go      | 420 ++++++++++++++++--------------
internal/mcp/server.go              | 145 ++++++----
internal/mcp/server_test.go         |  50 +++
7 files changed, 477 insertions(+), 329 deletions(-)

Detailed changes

.golangci.toml 🔗

@@ -0,0 +1,34 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+version = "2"
+
+[linters]
+enable = ["bodyclose", "errcheck", "errorlint", "exhaustive", "gocognit", "govet", "ineffassign", "modernize", "nilerr", "nilnil", "noctx", "revive", "staticcheck", "unused"]
+
+[linters.settings.gocognit]
+min-complexity = 15
+
+[[linters.settings.revive.rules]]
+name = "file-length-limit"
+arguments = [{ max = 1000, skip-comments = true, skip-blank-lines = true }]
+
+[[linters.settings.revive.rules]]
+name = "max-control-nesting"
+arguments = [5]
+
+[formatters]
+enable = ["goimports", "gofumpt", "gci", "golines"]
+
+[formatters.settings.goimports]
+local-prefixes = ["git.secluded.site/cooked-mcp"]
+
+[formatters.settings.gofumpt]
+module-path = "git.secluded.site/cooked-mcp"
+
+[formatters.settings.gci]
+sections = ["standard", "default", "localmodule"]
+
+[formatters.settings.golines]
+max-len = 120

internal/appconfig/config.go 🔗

@@ -63,12 +63,24 @@ func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Con
 		return Config{}, err
 	}
 
-	username, err := requiredConfigValue(ctx, resolver, "COOKED_LOGIN_USERNAME", fileConfig.User.Name, "Cooked username")
+	username, err := requiredConfigValue(
+		ctx,
+		resolver,
+		"COOKED_LOGIN_USERNAME",
+		fileConfig.User.Name,
+		"Cooked username",
+	)
 	if err != nil {
 		return Config{}, err
 	}
 
-	password, err := requiredConfigValue(ctx, resolver, "COOKED_LOGIN_PASSWORD", fileConfig.User.Password, "Cooked password")
+	password, err := requiredConfigValue(
+		ctx,
+		resolver,
+		"COOKED_LOGIN_PASSWORD",
+		fileConfig.User.Password,
+		"Cooked password",
+	)
 	if err != nil {
 		return Config{}, err
 	}
@@ -130,7 +142,11 @@ func loadFile(path string) (FileConfig, error) {
 	return fileConfig, nil
 }
 
-func requiredConfigValue(ctx context.Context, resolver *configvalue.Resolver, envName, config, description string) (string, error) {
+func requiredConfigValue(
+	ctx context.Context,
+	resolver *configvalue.Resolver,
+	envName, config, description string,
+) (string, error) {
 	if os.Getenv(envName) != "" || config != "" {
 		return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
 	}

internal/configvalue/configvalue.go 🔗

@@ -83,12 +83,20 @@ func (r *Resolver) ResolveOrThrow(ctx context.Context, config, description strin
 	case 1:
 		return "", fmt.Errorf("failed to resolve %s from environment variable: %s", description, missing[0])
 	default:
-		return "", fmt.Errorf("failed to resolve %s from environment variables: %s", description, strings.Join(missing, ", "))
+		return "", fmt.Errorf(
+			"failed to resolve %s from environment variables: %s",
+			description,
+			strings.Join(missing, ", "),
+		)
 	}
 }
 
 // ResolveWithExplicitEnvOverride resolves an explicit environment override before a config value.
-func ResolveWithExplicitEnvOverride(ctx context.Context, resolver *Resolver, explicitEnvName, configValue, description string) (string, error) {
+func ResolveWithExplicitEnvOverride(
+	ctx context.Context,
+	resolver *Resolver,
+	explicitEnvName, configValue, description string,
+) (string, error) {
 	if value := os.Getenv(explicitEnvName); value != "" {
 		return value, nil
 	}
@@ -151,76 +159,74 @@ func shellCommand(command string) (string, []string) {
 func parseTemplate(config string) []templatePart {
 	var parts []templatePart
 
-	appendLiteral := func(value string) {
-		if value == "" {
-			return
+	index := 0
+	for index < len(config) {
+		literal, part, hasPart, next := nextTemplatePart(config, index)
+		appendTemplateLiteral(&parts, literal)
+		if hasPart {
+			parts = append(parts, part)
 		}
+		index = next
+	}
 
-		last := len(parts) - 1
-		if last >= 0 && parts[last].kind == partLiteral {
-			parts[last].value += value
-			return
-		}
+	return parts
+}
 
-		parts = append(parts, templatePart{kind: partLiteral, value: value})
+func appendTemplateLiteral(parts *[]templatePart, value string) {
+	if value == "" {
+		return
 	}
 
-	index := 0
-	for index < len(config) {
-		relativeDollar := strings.IndexByte(config[index:], '$')
-		if relativeDollar < 0 {
-			appendLiteral(config[index:])
-			break
-		}
+	last := len(*parts) - 1
+	if last >= 0 && (*parts)[last].kind == partLiteral {
+		(*parts)[last].value += value
+		return
+	}
 
-		dollar := index + relativeDollar
-		appendLiteral(config[index:dollar])
+	*parts = append(*parts, templatePart{kind: partLiteral, value: value})
+}
 
-		if dollar+1 >= len(config) {
-			appendLiteral("$")
-			index = dollar + 1
-			continue
-		}
+func nextTemplatePart(config string, index int) (string, templatePart, bool, int) {
+	relativeDollar := strings.IndexByte(config[index:], '$')
+	if relativeDollar < 0 {
+		return config[index:], templatePart{}, false, len(config)
+	}
 
-		next := config[dollar+1]
-		if next == '$' || next == '!' {
-			appendLiteral(string(next))
-			index = dollar + 2
-			continue
-		}
+	dollar := index + relativeDollar
+	literal := config[index:dollar]
+	if dollar+1 >= len(config) {
+		return literal + "$", templatePart{}, false, dollar + 1
+	}
 
-		if next == '{' {
-			end := strings.IndexByte(config[dollar+2:], '}')
-			if end < 0 {
-				appendLiteral("$")
-				index = dollar + 1
-				continue
-			}
+	next := config[dollar+1]
+	if next == '$' || next == '!' {
+		return literal + string(next), templatePart{}, false, dollar + 2
+	}
+	if next == '{' {
+		return nextBracedTemplatePart(config, dollar, literal)
+	}
 
-			endIndex := dollar + 2 + end
-			name := config[dollar+2 : endIndex]
-			if envNameRE.MatchString(name) {
-				parts = append(parts, templatePart{kind: partEnv, value: name})
-			} else {
-				appendLiteral(config[dollar : endIndex+1])
-			}
+	name := envNamePrefixRE.FindString(config[dollar+1:])
+	if name == "" {
+		return literal + "$", templatePart{}, false, dollar + 1
+	}
 
-			index = endIndex + 1
-			continue
-		}
+	return literal, templatePart{kind: partEnv, value: name}, true, dollar + 1 + len(name)
+}
 
-		name := envNamePrefixRE.FindString(config[dollar+1:])
-		if name != "" {
-			parts = append(parts, templatePart{kind: partEnv, value: name})
-			index = dollar + 1 + len(name)
-			continue
-		}
+func nextBracedTemplatePart(config string, dollar int, literal string) (string, templatePart, bool, int) {
+	end := strings.IndexByte(config[dollar+2:], '}')
+	if end < 0 {
+		return literal + "$", templatePart{}, false, dollar + 1
+	}
 
-		appendLiteral("$")
-		index = dollar + 1
+	endIndex := dollar + 2 + end
+	name := config[dollar+2 : endIndex]
+	if !envNameRE.MatchString(name) {
+		return literal + config[dollar:endIndex+1], templatePart{}, false, endIndex + 1
 	}
 
-	return parts
+	return literal, templatePart{kind: partEnv, value: name}, true, endIndex + 1
 }
 
 func resolveTemplate(parts []templatePart) (string, bool) {

internal/cooked/client.go 🔗

@@ -101,7 +101,13 @@ func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
 	decodeShoppingList := func(decoder *json.Decoder) error {
 		return decoder.Decode(&response)
 	}
-	if err := c.doAuthenticated(ctx, http.MethodGet, "/api/user/{username}/shopping-list", nil, decodeShoppingList); err != nil {
+	if err := c.doAuthenticated(
+		ctx,
+		http.MethodGet,
+		"/api/user/{username}/shopping-list",
+		nil,
+		decodeShoppingList,
+	); err != nil {
 		return ShoppingList{}, err
 	}
 
@@ -167,7 +173,12 @@ func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, err
 	return response.Recipes, nil
 }
 
-func (c *Client) doAuthenticated(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
+func (c *Client) doAuthenticated(
+	ctx context.Context,
+	method, path string,
+	body []byte,
+	decodeResponse responseDecoder,
+) error {
 	c.requestMu.Lock()
 	defer c.requestMu.Unlock()
 

internal/cooked/client_test.go 🔗

@@ -15,61 +15,8 @@ import (
 
 func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
 	var sawSessionCookie bool
-
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.Path {
-		case "/api/public/login":
-			if r.Method != http.MethodPost {
-				t.Fatalf("login method = %s, want POST", r.Method)
-			}
-
-			var request loginRequest
-			if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
-				t.Fatalf("decode login request: %v", err)
-			}
-			if request.Username != "configured-user" || request.Password != "configured-password" {
-				t.Fatalf("login request = %#v, want configured credentials", request)
-			}
-
-			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
-			writeJSON(t, w, loginResponse{Username: "returned-user"})
-		case "/api/user/returned-user/shopping-list":
-			cookie, err := r.Cookie("cooked_session")
-			if err != nil {
-				t.Fatalf("missing session cookie: %v", err)
-			}
-			if cookie.Value != "session-value" {
-				t.Fatalf("session cookie = %q, want session-value", cookie.Value)
-			}
-			sawSessionCookie = true
-			writeJSON(t, w, shoppingListResponse{
-				ShoppingList: ShoppingList{
-					Aisles: []Aisle{{
-						ID:   "pantry",
-						Name: "Pantry",
-						ProductGroups: []ProductGroup{{
-							ID:       "pasta",
-							Name:     "Pasta",
-							Quantity: "200g",
-						}},
-					}},
-				},
-				Recipes: []string{"recipe-id"},
-			})
-		default:
-			t.Fatalf("unexpected path %s", r.URL.Path)
-		}
-	}))
-	defer server.Close()
-
-	baseURL, err := url.Parse(server.URL)
-	if err != nil {
-		t.Fatalf("parse server URL: %v", err)
-	}
-	client, err := NewClient(baseURL, "configured-user", "configured-password")
-	if err != nil {
-		t.Fatalf("NewClient() error = %v", err)
-	}
+	client, closeServer := newTestClient(t, shoppingListTestHandler(t, &sawSessionCookie))
+	defer closeServer()
 
 	shoppingList, err := client.ReadShoppingList(context.Background())
 	if err != nil {
@@ -85,39 +32,8 @@ func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
 }
 
 func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.Path {
-		case "/api/public/login":
-			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
-			writeJSON(t, w, loginResponse{Username: "returned-user"})
-		case "/api/user/returned-user/recipes":
-			if got := r.URL.Query().Get("page"); got != "2" {
-				t.Fatalf("page query = %q, want 2", got)
-			}
-			if got := r.URL.Query().Get("page-count"); got != "5" {
-				t.Fatalf("page-count query = %q, want 5", got)
-			}
-			writeJSON(t, w, recipeListResponse{
-				Recipes: []RecipeCard{{
-					ID:           "recipe-1",
-					Title:        "Pasta",
-					ThumbnailURL: "https://example.invalid/thumb.jpg",
-				}},
-			})
-		default:
-			t.Fatalf("unexpected path %s", r.URL.Path)
-		}
-	}))
-	defer server.Close()
-
-	baseURL, err := url.Parse(server.URL)
-	if err != nil {
-		t.Fatalf("parse server URL: %v", err)
-	}
-	client, err := NewClient(baseURL, "configured-user", "configured-password")
-	if err != nil {
-		t.Fatalf("NewClient() error = %v", err)
-	}
+	client, closeServer := newTestClient(t, listRecipesTestHandler(t))
+	defer closeServer()
 
 	recipes, err := client.ListRecipes(context.Background(), 2, 5)
 	if err != nil {
@@ -133,42 +49,8 @@ func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
 }
 
 func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.Path {
-		case "/api/public/login":
-			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
-			writeJSON(t, w, loginResponse{Username: "returned-user"})
-		case "/api/user/returned-user/recipes/search":
-			if r.Method != http.MethodGet {
-				t.Fatalf("search method = %s, want GET", r.Method)
-			}
-			if got := r.URL.Query().Get("q"); got != "pasta & tomato" {
-				t.Fatalf("q query = %q, want pasta & tomato", got)
-			}
-			if got := r.URL.Query().Get("page"); got != "3" {
-				t.Fatalf("page query = %q, want 3", got)
-			}
-			writeJSON(t, w, recipeListResponse{
-				Recipes: []RecipeCard{{
-					ID:           "recipe-1",
-					Title:        "Pasta",
-					ThumbnailURL: "https://example.invalid/thumb.jpg",
-				}},
-			})
-		default:
-			t.Fatalf("unexpected path %s", r.URL.Path)
-		}
-	}))
-	defer server.Close()
-
-	baseURL, err := url.Parse(server.URL)
-	if err != nil {
-		t.Fatalf("parse server URL: %v", err)
-	}
-	client, err := NewClient(baseURL, "configured-user", "configured-password")
-	if err != nil {
-		t.Fatalf("NewClient() error = %v", err)
-	}
+	client, closeServer := newTestClient(t, searchRecipesTestHandler(t))
+	defer closeServer()
 
 	recipes, err := client.SearchRecipes(context.Background(), "pasta & tomato", 3)
 	if err != nil {
@@ -184,46 +66,8 @@ func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
 }
 
 func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.EscapedPath() {
-		case "/api/public/login":
-			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
-			writeJSON(t, w, loginResponse{Username: "returned-user"})
-		case "/api/recipe/recipe%2Fwith%20space/metadata":
-			if r.Method != http.MethodGet {
-				t.Fatalf("metadata method = %s, want GET", r.Method)
-			}
-			cookie, err := r.Cookie("cooked_session")
-			if err != nil {
-				t.Fatalf("missing session cookie: %v", err)
-			}
-			if cookie.Value != "session-value" {
-				t.Fatalf("session cookie = %q, want session-value", cookie.Value)
-			}
-
-			w.Header().Set("Content-Type", "application/json")
-			if _, err := w.Write([]byte(`{
-				"title": "Pasta with Tomato Sauce",
-				"image-urls": ["https://example.invalid/pasta.jpg"],
-				"owner": "returned-user",
-				"edit-permission": true
-			}`)); err != nil {
-				t.Fatalf("write metadata response: %v", err)
-			}
-		default:
-			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
-		}
-	}))
-	defer server.Close()
-
-	baseURL, err := url.Parse(server.URL)
-	if err != nil {
-		t.Fatalf("parse server URL: %v", err)
-	}
-	client, err := NewClient(baseURL, "configured-user", "configured-password")
-	if err != nil {
-		t.Fatalf("NewClient() error = %v", err)
-	}
+	client, closeServer := newTestClient(t, recipeMetadataTestHandler(t))
+	defer closeServer()
 
 	metadata, err := client.ReadRecipeMetadata(context.Background(), "recipe/with space")
 	if err != nil {
@@ -242,41 +86,8 @@ func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
 }
 
 func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing.T) {
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.EscapedPath() {
-		case "/api/public/login":
-			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
-			writeJSON(t, w, loginResponse{Username: "returned-user"})
-		case "/api/recipe/recipe%2Fwith%20space/content":
-			if r.Method != http.MethodGet {
-				t.Fatalf("content method = %s, want GET", r.Method)
-			}
-			cookie, err := r.Cookie("cooked_session")
-			if err != nil {
-				t.Fatalf("missing session cookie: %v", err)
-			}
-			if cookie.Value != "session-value" {
-				t.Fatalf("session cookie = %q, want session-value", cookie.Value)
-			}
-
-			writeJSON(t, w, RecipeContent{
-				Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
-				Portions: 2,
-			})
-		default:
-			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
-		}
-	}))
-	defer server.Close()
-
-	baseURL, err := url.Parse(server.URL)
-	if err != nil {
-		t.Fatalf("parse server URL: %v", err)
-	}
-	client, err := NewClient(baseURL, "configured-user", "configured-password")
-	if err != nil {
-		t.Fatalf("NewClient() error = %v", err)
-	}
+	client, closeServer := newTestClient(t, recipeContentTestHandler(t))
+	defer closeServer()
 
 	content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
 	if err != nil {
@@ -307,7 +118,12 @@ func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
 	}
 	client := &Client{baseURL: baseURL}
 
-	request, err := client.newRequest(context.Background(), http.MethodGet, "/api/user/user%2Fname/recipes?page=2&page-count=5", nil)
+	request, err := client.newRequest(
+		context.Background(),
+		http.MethodGet,
+		"/api/user/user%2Fname/recipes?page=2&page-count=5",
+		nil,
+	)
 	if err != nil {
 		t.Fatalf("newRequest() error = %v", err)
 	}
@@ -317,7 +133,209 @@ func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
 	}
 }
 
-func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](t *testing.T, w http.ResponseWriter, value T) {
+func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
+	t.Helper()
+
+	server := httptest.NewServer(handler)
+	baseURL, err := url.Parse(server.URL)
+	if err != nil {
+		server.Close()
+		t.Fatalf("parse server URL: %v", err)
+	}
+	client, err := NewClient(baseURL, "configured-user", "configured-password")
+	if err != nil {
+		server.Close()
+		t.Fatalf("NewClient() error = %v", err)
+	}
+
+	return client, server.Close
+}
+
+func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/api/public/login":
+			requireLoginRequest(t, r)
+			writeLoginResponse(t, w)
+		case "/api/user/returned-user/shopping-list":
+			requireSessionCookie(t, r)
+			*sawSessionCookie = true
+			writeShoppingListResponse(t, w)
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}
+
+func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/user/returned-user/recipes":
+			requireQueryValue(t, r, "page", "2")
+			requireQueryValue(t, r, "page-count", "5")
+			writeRecipeListResponse(t, w)
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}
+
+func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/user/returned-user/recipes/search":
+			requireMethod(t, r, http.MethodGet, "search")
+			requireQueryValue(t, r, "q", "pasta & tomato")
+			requireQueryValue(t, r, "page", "3")
+			writeRecipeListResponse(t, w)
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}
+
+func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.EscapedPath() {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/recipe/recipe%2Fwith%20space/metadata":
+			requireMethod(t, r, http.MethodGet, "metadata")
+			requireSessionCookie(t, r)
+			writeRecipeMetadataResponse(t, w)
+		default:
+			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+		}
+	}
+}
+
+func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.EscapedPath() {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/recipe/recipe%2Fwith%20space/content":
+			requireMethod(t, r, http.MethodGet, "content")
+			requireSessionCookie(t, r)
+			writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
+		default:
+			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+		}
+	}
+}
+
+func requireLoginRequest(t *testing.T, r *http.Request) {
+	t.Helper()
+	requireMethod(t, r, http.MethodPost, "login")
+
+	var request loginRequest
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		t.Fatalf("decode login request: %v", err)
+	}
+	if request.Username != "configured-user" || request.Password != "configured-password" {
+		t.Fatalf("login request = %#v, want configured credentials", request)
+	}
+}
+
+func requireMethod(t *testing.T, r *http.Request, want string, label string) {
+	t.Helper()
+
+	if r.Method != want {
+		t.Fatalf("%s method = %s, want %s", label, r.Method, want)
+	}
+}
+
+func requireSessionCookie(t *testing.T, r *http.Request) {
+	t.Helper()
+
+	cookie, err := r.Cookie("cooked_session")
+	if err != nil {
+		t.Fatalf("missing session cookie: %v", err)
+	}
+	if cookie.Value != "session-value" {
+		t.Fatalf("session cookie = %q, want session-value", cookie.Value)
+	}
+}
+
+func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
+	t.Helper()
+
+	if got := r.URL.Query().Get(name); got != want {
+		t.Fatalf("%s query = %q, want %q", name, got, want)
+	}
+}
+
+func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
+	t.Helper()
+
+	http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
+	writeJSON(t, w, loginResponse{Username: "returned-user"})
+}
+
+func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
+	t.Helper()
+
+	writeJSON(t, w, shoppingListResponse{
+		ShoppingList: ShoppingList{
+			Aisles: []Aisle{{
+				ID:   "pantry",
+				Name: "Pantry",
+				ProductGroups: []ProductGroup{{
+					ID:       "pasta",
+					Name:     "Pasta",
+					Quantity: "200g",
+				}},
+			}},
+		},
+		Recipes: []string{"recipe-id"},
+	})
+}
+
+func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
+	t.Helper()
+
+	writeJSON(t, w, recipeListResponse{
+		Recipes: []RecipeCard{{
+			ID:           "recipe-1",
+			Title:        "Pasta",
+			ThumbnailURL: "https://example.invalid/thumb.jpg",
+		}},
+	})
+}
+
+func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
+	t.Helper()
+
+	w.Header().Set("Content-Type", "application/json")
+	if _, err := w.Write([]byte(`{
+		"title": "Pasta with Tomato Sauce",
+		"image-urls": ["https://example.invalid/pasta.jpg"],
+		"owner": "returned-user",
+		"edit-permission": true
+	}`)); err != nil {
+		t.Fatalf("write metadata response: %v", err)
+	}
+}
+
+func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](
+	t *testing.T,
+	w http.ResponseWriter,
+	value T,
+) {
 	t.Helper()
 
 	w.Header().Set("Content-Type", "application/json")

internal/mcp/server.go 🔗

@@ -59,72 +59,96 @@ func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (Rea
 	return output, nil
 }
 
-func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
+func (s *Server) callReadTool(
+	ctx context.Context,
+	_ *sdk.CallToolRequest,
+	arguments ReadArguments,
+) (*sdk.CallToolResult, ReadOutput, error) {
 	switch arguments.Target {
 	case "shopping_list":
-		shoppingList, err := s.backend.ReadShoppingList(ctx)
-		if err != nil {
-			return nil, ReadOutput{}, err
-		}
+		return s.callShoppingListReadTool(ctx)
+	case "recipe":
+		return s.callRecipeReadTool(ctx, arguments.RecipeID)
+	case "recipes":
+		return s.callRecipesReadTool(ctx, arguments)
+	default:
+		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
+	}
+}
 
-		output := ReadOutput{
-			Aisles:              shoppingList.Aisles,
-			ShoppingListRecipes: shoppingList.Recipes,
-		}
+func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
+	shoppingList, err := s.backend.ReadShoppingList(ctx)
+	if err != nil {
+		return nil, ReadOutput{}, err
+	}
 
-		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
-	case "recipe":
-		recipeID := strings.TrimSpace(arguments.RecipeID)
-		if recipeID == "" {
-			return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
-		}
+	output := ReadOutput{
+		Aisles:              shoppingList.Aisles,
+		ShoppingListRecipes: shoppingList.Recipes,
+	}
 
-		metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
-		if err != nil {
-			return nil, ReadOutput{}, err
-		}
-		content, err := s.backend.ReadRecipeContent(ctx, recipeID)
-		if err != nil {
-			return nil, ReadOutput{}, err
-		}
+	return &sdk.CallToolResult{
+		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
+	}, output, nil
+}
 
-		recipe := RecipeDetail{
-			ID:             recipeID,
-			Title:          metadata.Title,
-			Owner:          metadata.Owner,
-			EditPermission: metadata.EditPermission,
-			Content:        content.Content,
-			Portions:       content.Portions,
-		}
-		output := ReadOutput{Recipe: &recipe}
+func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
+	recipeID := strings.TrimSpace(rawRecipeID)
+	if recipeID == "" {
+		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
+	}
 
-		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
-	case "recipes":
-		page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
-		query := strings.TrimSpace(arguments.Query)
-
-		var (
-			recipes []cooked.RecipeCard
-			err     error
-		)
-		if query == "" {
-			recipes, err = s.backend.ListRecipes(ctx, page, limit)
-		} else {
-			recipes, err = s.backend.SearchRecipes(ctx, query, page)
-			if len(recipes) > limit {
-				recipes = recipes[:limit]
-			}
-		}
-		if err != nil {
-			return nil, ReadOutput{}, err
-		}
+	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
+	if err != nil {
+		return nil, ReadOutput{}, err
+	}
+	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
+	if err != nil {
+		return nil, ReadOutput{}, err
+	}
 
-		output := ReadOutput{Recipes: recipeSummaries(recipes)}
+	recipe := RecipeDetail{
+		ID:             recipeID,
+		Title:          metadata.Title,
+		Owner:          metadata.Owner,
+		EditPermission: metadata.EditPermission,
+		Content:        content.Content,
+		Portions:       content.Portions,
+	}
+	output := ReadOutput{Recipe: &recipe}
 
-		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
-	default:
-		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
+	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
+}
+
+func (s *Server) callRecipesReadTool(
+	ctx context.Context,
+	arguments ReadArguments,
+) (*sdk.CallToolResult, ReadOutput, error) {
+	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
+	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
+	if err != nil {
+		return nil, ReadOutput{}, err
 	}
+
+	output := ReadOutput{Recipes: recipeSummaries(recipes)}
+
+	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
+}
+
+func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
+	if query == "" {
+		return s.backend.ListRecipes(ctx, page, limit)
+	}
+
+	recipes, err := s.backend.SearchRecipes(ctx, query, page)
+	if err != nil {
+		return nil, err
+	}
+	if len(recipes) > limit {
+		recipes = recipes[:limit]
+	}
+
+	return recipes, nil
 }
 
 func readTool() *sdk.Tool {
@@ -154,6 +178,7 @@ func normalizeRecipePage(page, limit int) (int, int) {
 
 	return page, limit
 }
+
 func formatShoppingList(shoppingList cooked.ShoppingList) string {
 	if len(shoppingList.Aisles) == 0 {
 		return "Shopping list is empty."
@@ -251,11 +276,11 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 
 // ReadArguments contains read tool arguments.
 type ReadArguments struct {
-	Target   string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
+	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
 	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
-	Query    string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
-	Page     int    `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
-	Limit    int    `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
+	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
+	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
+	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
 }
 
 // ReadOutput is the structured output for the current read tool slice.

internal/mcp/server_test.go 🔗

@@ -43,7 +43,11 @@ func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
 }
 
 func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
-	backend := &fakeBackend{recipes: []cooked.RecipeCard{{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}}}
+	backend := &fakeBackend{
+		recipes: []cooked.RecipeCard{
+			{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
+		},
+	}
 	server := NewServer(backend, "test")
 
 	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
@@ -128,17 +132,38 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
 	}
 	server := NewServer(backend, "test")
 
-	result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "recipe", RecipeID: "recipe-1"})
+	result, output, err := server.callReadTool(
+		context.Background(),
+		nil,
+		ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
+	)
 	if err != nil {
 		t.Fatalf("callReadTool() error = %v", err)
 	}
 
+	requireSingleRecipeBackendCalls(t, backend)
+	requireSingleRecipeOutput(t, output)
+	requireNoImageLeak(t, result, output)
+}
+
+func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
+	t.Helper()
+
 	if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
-		t.Fatalf("backend recipe IDs = metadata %q content %q, want recipe-1", backend.metadataRecipeID, backend.contentRecipeID)
+		t.Fatalf(
+			"backend recipe IDs = metadata %q content %q, want recipe-1",
+			backend.metadataRecipeID,
+			backend.contentRecipeID,
+		)
 	}
 	if backend.metadataCalls != 1 || backend.contentCalls != 1 {
 		t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
 	}
+}
+
+func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
+	t.Helper()
+
 	if output.Recipe == nil {
 		t.Fatal("structured recipe is nil")
 	}
@@ -146,17 +171,30 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
 		t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
 	}
 	if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
-		t.Fatalf("structured recipe owner/edit = %q/%v, want returned-user/true", output.Recipe.Owner, output.Recipe.EditPermission)
+		t.Fatalf(
+			"structured recipe owner/edit = %q/%v, want returned-user/true",
+			output.Recipe.Owner,
+			output.Recipe.EditPermission,
+		)
 	}
 	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
-		t.Fatalf("structured recipe content/portions = %q/%d, want markdown/2", output.Recipe.Content, output.Recipe.Portions)
+		t.Fatalf(
+			"structured recipe content/portions = %q/%d, want markdown/2",
+			output.Recipe.Content,
+			output.Recipe.Portions,
+		)
 	}
+}
+
+func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
+	t.Helper()
 
 	encodedOutput, err := json.Marshal(output)
 	if err != nil {
 		t.Fatalf("marshal output: %v", err)
 	}
-	if strings.Contains(string(encodedOutput), "image") || strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
+	if strings.Contains(string(encodedOutput), "image") ||
+		strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
 		t.Fatalf("structured output leaked image data: %s", encodedOutput)
 	}
 	if len(result.Content) != 1 {