From 4bb65c7550a87a5f1eee36fbb004a70e8c9ae0c4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 19:23:00 -0600 Subject: [PATCH] mcp: include structured tool results --- 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(-) create mode 100644 internal/mcp/server_structured_test.go diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 039db2cb60c2448795eea8bd6440848c50676722..35a572584faf1f55572ce93d7b897926f96136e5 100644 --- a/internal/mcp/server.go +++ b/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 { diff --git a/internal/mcp/server_structured_test.go b/internal/mcp/server_structured_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b5055cbf6a2ac5c53d895fde619919156060fd76 --- /dev/null +++ b/internal/mcp/server_structured_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 7b9e12172ac77abf2ba18841c58cba5f0c293aae..75ea61afbf789a5446b7f6a9bfe4658d15ceafde 100644 --- a/internal/mcp/server_test.go +++ b/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) {