mcp: include structured tool results

Amolith created

Change summary

internal/mcp/server.go                 | 75 ++++++++++-----------------
internal/mcp/server_structured_test.go | 55 ++++++++++++++++++++
internal/mcp/server_test.go            | 27 +++------
3 files changed, 91 insertions(+), 66 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -113,9 +113,7 @@ func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolRes
 		ShoppingListRecipes: shoppingList.Recipes,
 	}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
-	}, output, nil
+	return newToolResult(formatShoppingList(shoppingList), output), output, nil
 }
 
 func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
@@ -139,7 +137,7 @@ func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*s
 	}
 	output := ReadOutput{Recipe: &recipe}
 
-	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
+	return newToolResult(formatRecipe(recipe), output), output, nil
 }
 
 func (s *Server) readRecipeParts(
@@ -175,7 +173,7 @@ func (s *Server) callRecipesReadTool(
 		text += "\n\n" + paginationHint
 	}
 
-	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: text}}}, output, nil
+	return newToolResult(text, output), output, nil
 }
 
 func (s *Server) readRecipeCards(
@@ -235,9 +233,7 @@ func (s *Server) callPreviewRecipeTextTool(
 		Portions: preview.Portions,
 	}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
-	}, output, nil
+	return newToolResult(formatRecipeTextPreview(output), output), output, nil
 }
 
 func (s *Server) callSaveRecipeTool(
@@ -328,7 +324,7 @@ func (s *Server) callURLSaveRecipeTool(
 
 	recipeID := strings.TrimSpace(imported.RecipeID)
 	if recipeID != "" {
-		return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+		return newRecipeSaveResult(recipeID)
 	}
 
 	draftID := strings.TrimSpace(imported.DraftID)
@@ -348,9 +344,7 @@ func (s *Server) callURLSaveRecipeTool(
 		Portions: content.Portions,
 	}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeURLImportDraft(output)}},
-	}, output, nil
+	return newToolResult(formatRecipeURLImportDraft(output), output), output, nil
 }
 
 func (s *Server) callDraftSaveRecipeTool(
@@ -373,7 +367,7 @@ func (s *Server) callDraftSaveRecipeTool(
 		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe draft response missing recipe ID")
 	}
 
-	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+	return newRecipeSaveResult(recipeID)
 }
 
 func (s *Server) callExistingSaveRecipeTool(
@@ -392,7 +386,7 @@ func (s *Server) callExistingSaveRecipeTool(
 		return nil, SaveRecipeOutput{}, err
 	}
 
-	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+	return newRecipeSaveResult(recipeID)
 }
 
 func validateSaveRecipeContent(source, markdown string, portions int) error {
@@ -419,15 +413,13 @@ func (s *Server) savePreparedRecipe(
 		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
 	}
 
-	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+	return newRecipeSaveResult(recipeID)
 }
 
-func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
+func newRecipeSaveResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 	output := SaveRecipeOutput{RecipeID: recipeID}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
-	}
+	return newToolResult(formatRecipeSave(output), output), output, nil
 }
 
 func (s *Server) callDeleteRecipeTool(
@@ -446,9 +438,7 @@ func (s *Server) callDeleteRecipeTool(
 
 	output := DeleteRecipeOutput{RecipeID: recipeID}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
-	}, output, nil
+	return newToolResult(formatRecipeDelete(output), output), output, nil
 }
 
 func (s *Server) callChangeShoppingListTool(
@@ -500,13 +490,9 @@ func (s *Server) addShoppingListIngredients(
 	}
 
 	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
+	text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-			"Added %d shopping-list ingredients.",
-			result.AddedCount,
-		)}},
-	}, output, nil
+	return newToolResult(text, output), output, nil
 }
 
 func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
@@ -516,9 +502,7 @@ func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, Ch
 
 	output := ChangeShoppingListOutput{Cleared: true}
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
-	}, output, nil
+	return newToolResult("Cleared shopping list.", output), output, nil
 }
 
 func (s *Server) removeShoppingListProductGroups(
@@ -535,13 +519,9 @@ func (s *Server) removeShoppingListProductGroups(
 	}
 
 	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
+	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-			"Removed %d shopping-list product groups.",
-			len(ids),
-		)}},
-	}, output, nil
+	return newToolResult(text, output), output, nil
 }
 
 func (s *Server) replaceShoppingListSelection(
@@ -611,13 +591,9 @@ func (s *Server) setShoppingListSelection(
 	}
 
 	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
+	text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
 
-	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-			"Selected %d shopping-list product groups.",
-			len(ids),
-		)}},
-	}, output, nil
+	return newToolResult(text, output), output, nil
 }
 
 func (s *Server) updateShoppingListProductGroup(
@@ -668,13 +644,16 @@ func (s *Server) updateShoppingListProductGroup(
 	}
 
 	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
+	text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)
 
+	return newToolResult(text, output), output, nil
+}
+
+func newToolResult[Output any](text string, output Output) *sdk.CallToolResult {
 	return &sdk.CallToolResult{
-		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
-			"Updated shopping-list product group (id: %s).",
-			productGroupID,
-		)}},
-	}, output, nil
+		Content:           []sdk.Content{&sdk.TextContent{Text: text}},
+		StructuredContent: output,
+	}
 }
 
 func readTool() *sdk.Tool {

internal/mcp/server_structured_test.go 🔗

@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+	"encoding/json"
+	"testing"
+
+	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func requireTextContent(t *testing.T, result *sdk.CallToolResult) string {
+	t.Helper()
+
+	if len(result.Content) != 1 {
+		t.Fatalf("text content length = %d, want 1", len(result.Content))
+	}
+	textContent, ok := result.Content[0].(*sdk.TextContent)
+	if !ok {
+		t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
+	}
+
+	return textContent.Text
+}
+
+func requireStructuredContent(t *testing.T, result *sdk.CallToolResult, want any) {
+	t.Helper()
+	// tools.RESPONSES.5
+
+	if result == nil {
+		t.Fatal("result is nil")
+	}
+	if result.StructuredContent == nil {
+		t.Fatal("structured content is nil")
+	}
+
+	gotJSON, err := json.Marshal(result.StructuredContent)
+	if err != nil {
+		t.Fatalf("marshal structured content: %v", err)
+	}
+	wantJSON, err := json.Marshal(want)
+	if err != nil {
+		t.Fatalf("marshal wanted structured content: %v", err)
+	}
+	if string(gotJSON) != string(wantJSON) {
+		t.Fatalf("structured content = %s, want %s", gotJSON, wantJSON)
+	}
+
+	var object map[string]any
+	if err := json.Unmarshal(gotJSON, &object); err != nil {
+		t.Fatalf("structured content is not a JSON object: %v", err)
+	}
+}

internal/mcp/server_test.go 🔗

@@ -144,6 +144,7 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
 
 	requireSingleRecipeBackendCalls(t, backend)
 	requireSingleRecipeOutput(t, output)
+	requireStructuredContent(t, result, output)
 	requireNoImageLeak(t, result, output)
 }
 
@@ -204,20 +205,6 @@ func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOut
 	}
 }
 
-func requireTextContent(t *testing.T, result *sdk.CallToolResult) string {
-	t.Helper()
-
-	if len(result.Content) != 1 {
-		t.Fatalf("text content length = %d, want 1", len(result.Content))
-	}
-	textContent, ok := result.Content[0].(*sdk.TextContent)
-	if !ok {
-		t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
-	}
-
-	return textContent.Text
-}
-
 func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
 	backend := &fakeBackend{}
 	server := NewServer(backend, "test")
@@ -240,7 +227,7 @@ func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
 	}}
 	server := NewServer(backend, "test")
 
-	_, output, err := server.callPreviewRecipeTextTool(
+	result, output, err := server.callPreviewRecipeTextTool(
 		context.Background(),
 		nil,
 		PreviewRecipeTextArguments{Title: " Pasta ", Text: "  Boil pasta.\n"},
@@ -262,6 +249,7 @@ func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
 		output.Portions != 2 {
 		t.Fatalf("preview output = %#v, want title/markdown/portions", output)
 	}
+	requireStructuredContent(t, result, output)
 }
 
 func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
@@ -302,7 +290,7 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
 	backend := &fakeBackend{saveRecipeID: "recipe-1"}
 	server := NewServer(backend, "test")
 
-	_, output, err := server.callSaveRecipeTool(
+	result, output, err := server.callSaveRecipeTool(
 		context.Background(),
 		nil,
 		SaveRecipeArguments{
@@ -331,6 +319,7 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
 	if output.RecipeID != "recipe-1" {
 		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
 	}
+	requireStructuredContent(t, result, output)
 }
 
 func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
@@ -510,7 +499,7 @@ func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
 	backend := &fakeBackend{}
 	server := NewServer(backend, "test")
 
-	_, output, err := server.callDeleteRecipeTool(
+	result, output, err := server.callDeleteRecipeTool(
 		context.Background(),
 		nil,
 		DeleteRecipeArguments{RecipeID: " recipe-1 "},
@@ -528,6 +517,7 @@ func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
 	if output.RecipeID != "recipe-1" {
 		t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
 	}
+	requireStructuredContent(t, result, output)
 }
 
 func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
@@ -569,7 +559,7 @@ func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
 	backend := &fakeBackend{}
 	server := NewServer(backend, "test")
 
-	_, output, err := server.callChangeShoppingListTool(
+	result, output, err := server.callChangeShoppingListTool(
 		context.Background(),
 		nil,
 		ChangeShoppingListArguments{Action: " clear "},
@@ -584,6 +574,7 @@ func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
 	if !output.Cleared {
 		t.Fatalf("clear output cleared = %v, want true", output.Cleared)
 	}
+	requireStructuredContent(t, result, output)
 }
 
 func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {