refactor(mcp): add natural language tool outputs

Amolith created

- Add human-readable text content to all tool results
- Remove duplicate ID fields; use deep_link only
- Reduces context window bloat for LLM clients
- structuredContent still available for programmatic use

Assisted-by: Claude Sonnet 4 via Crush

Change summary

internal/mcp/tools/task/create.go       | 10 ++--
internal/mcp/tools/task/delete.go       | 15 ++++--
internal/mcp/tools/task/list.go         | 40 ++++++++++++++---
internal/mcp/tools/task/show.go         | 60 +++++++++++++++++++++++++-
internal/mcp/tools/task/update.go       | 10 ++--
internal/mcp/tools/timestamp/handler.go | 17 ++++++-
6 files changed, 123 insertions(+), 29 deletions(-)

Detailed changes

internal/mcp/tools/task/create.go 🔗

@@ -54,7 +54,6 @@ type CreateInput struct {
 
 // CreateOutput is the output schema for creating a task.
 type CreateOutput struct {
-	ID       string `json:"id"`
 	DeepLink string `json:"deep_link"`
 }
 
@@ -108,10 +107,11 @@ func (h *Handler) HandleCreate(
 
 	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
 
-	return nil, CreateOutput{
-		ID:       task.ID,
-		DeepLink: deepLink,
-	}, nil
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: "Task created: " + deepLink,
+		}},
+	}, CreateOutput{DeepLink: deepLink}, nil
 }
 
 //nolint:cyclop,funlen

internal/mcp/tools/task/delete.go 🔗

@@ -30,8 +30,8 @@ type DeleteInput struct {
 
 // DeleteOutput is the output schema for deleting a task.
 type DeleteOutput struct {
-	Success bool   `json:"success"`
-	ID      string `json:"id"`
+	Success  bool   `json:"success"`
+	DeepLink string `json:"deep_link"`
 }
 
 // HandleDelete deletes a task.
@@ -45,12 +45,15 @@ func (h *Handler) HandleDelete(
 		return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), DeleteOutput{}, nil
 	}
 
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, id)
+
 	if _, err := h.client.DeleteTask(ctx, id); err != nil {
 		return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
 	}
 
-	return nil, DeleteOutput{
-		Success: true,
-		ID:      id,
-	}, nil
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: "Task deleted: " + deepLink,
+		}},
+	}, DeleteOutput{Success: true, DeepLink: deepLink}, nil
 }

internal/mcp/tools/task/list.go 🔗

@@ -6,6 +6,8 @@ package task
 
 import (
 	"context"
+	"fmt"
+	"strings"
 	"time"
 
 	"git.secluded.site/go-lunatask"
@@ -44,7 +46,6 @@ type ListOutput struct {
 
 // Summary represents a task in list output.
 type Summary struct {
-	ID          string  `json:"id"`
 	DeepLink    string  `json:"deep_link"`
 	Status      *string `json:"status,omitempty"`
 	Priority    *int    `json:"priority,omitempty"`
@@ -90,11 +91,14 @@ func (h *Handler) HandleList(
 
 	filtered := lunatask.FilterTasks(tasks, opts)
 	summaries := buildSummaries(filtered)
-
-	return nil, ListOutput{
-		Tasks: summaries,
-		Count: len(summaries),
-	}, nil
+	text := formatListText(summaries)
+
+	return &mcp.CallToolResult{
+			Content: []mcp.Content{&mcp.TextContent{Text: text}},
+		}, ListOutput{
+			Tasks: summaries,
+			Count: len(summaries),
+		}, nil
 }
 
 func buildSummaries(tasks []lunatask.Task) []Summary {
@@ -102,7 +106,6 @@ func buildSummaries(tasks []lunatask.Task) []Summary {
 
 	for _, task := range tasks {
 		summary := Summary{
-			ID:        task.ID,
 			CreatedAt: task.CreatedAt.Format(time.RFC3339),
 			AreaID:    task.AreaID,
 			GoalID:    task.GoalID,
@@ -130,3 +133,26 @@ func buildSummaries(tasks []lunatask.Task) []Summary {
 
 	return summaries
 }
+
+func formatListText(summaries []Summary) string {
+	if len(summaries) == 0 {
+		return "No tasks found."
+	}
+
+	var builder strings.Builder
+
+	builder.WriteString(fmt.Sprintf("Found %d task(s):\n", len(summaries)))
+
+	for _, summary := range summaries {
+		status := "unknown"
+		if summary.Status != nil {
+			status = *summary.Status
+		}
+
+		builder.WriteString(fmt.Sprintf("- %s (%s)\n", summary.DeepLink, status))
+	}
+
+	builder.WriteString("\nUse show_task for full details.")
+
+	return builder.String()
+}

internal/mcp/tools/task/show.go 🔗

@@ -6,6 +6,8 @@ package task
 
 import (
 	"context"
+	"fmt"
+	"strings"
 	"time"
 
 	"git.secluded.site/go-lunatask"
@@ -32,7 +34,6 @@ type ShowInput struct {
 
 // ShowOutput is the output schema for showing a task.
 type ShowOutput struct {
-	ID          string  `json:"id"`
 	DeepLink    string  `json:"deep_link"`
 	Status      *string `json:"status,omitempty"`
 	Priority    *int    `json:"priority,omitempty"`
@@ -64,7 +65,6 @@ func (h *Handler) HandleShow(
 	}
 
 	output := ShowOutput{
-		ID:        task.ID,
 		CreatedAt: task.CreatedAt.Format(time.RFC3339),
 		UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
 		AreaID:    task.AreaID,
@@ -104,5 +104,59 @@ func (h *Handler) HandleShow(
 		output.Urgent = &urgent
 	}
 
-	return nil, output, nil
+	text := formatShowText(output)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func formatShowText(output ShowOutput) string {
+	var builder strings.Builder
+
+	builder.WriteString(fmt.Sprintf("Task: %s\n", output.DeepLink))
+	writeOptionalField(&builder, "Status", output.Status)
+	writeOptionalIntField(&builder, "Priority", output.Priority)
+	writeOptionalField(&builder, "Scheduled", output.ScheduledOn)
+	writeOptionalMinutesField(&builder, "Estimate", output.Estimate)
+	writeEisenhowerField(&builder, output.Important, output.Urgent)
+	builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt))
+	builder.WriteString("Updated: " + output.UpdatedAt)
+	writeOptionalField(&builder, "\nCompleted", output.CompletedAt)
+
+	return builder.String()
+}
+
+func writeOptionalField(builder *strings.Builder, label string, value *string) {
+	if value != nil {
+		fmt.Fprintf(builder, "%s: %s\n", label, *value)
+	}
+}
+
+func writeOptionalIntField(builder *strings.Builder, label string, value *int) {
+	if value != nil {
+		fmt.Fprintf(builder, "%s: %d\n", label, *value)
+	}
+}
+
+func writeOptionalMinutesField(builder *strings.Builder, label string, value *int) {
+	if value != nil {
+		fmt.Fprintf(builder, "%s: %d min\n", label, *value)
+	}
+}
+
+func writeEisenhowerField(builder *strings.Builder, important, urgent *bool) {
+	var parts []string
+
+	if important != nil && *important {
+		parts = append(parts, "important")
+	}
+
+	if urgent != nil && *urgent {
+		parts = append(parts, "urgent")
+	}
+
+	if len(parts) > 0 {
+		fmt.Fprintf(builder, "Eisenhower: %s\n", strings.Join(parts, ", "))
+	}
 }

internal/mcp/tools/task/update.go 🔗

@@ -55,7 +55,6 @@ type UpdateInput struct {
 
 // UpdateOutput is the output schema for updating a task.
 type UpdateOutput struct {
-	ID       string `json:"id"`
 	DeepLink string `json:"deep_link"`
 }
 
@@ -96,10 +95,11 @@ func (h *Handler) HandleUpdate(
 
 	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
 
-	return nil, UpdateOutput{
-		ID:       task.ID,
-		DeepLink: deepLink,
-	}, nil
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: "Task updated: " + deepLink,
+		}},
+	}, UpdateOutput{DeepLink: deepLink}, nil
 }
 
 //nolint:cyclop,funlen

internal/mcp/tools/timestamp/handler.go 🔗

@@ -7,6 +7,7 @@ package timestamp
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"git.secluded.site/lune/internal/dateutil"
@@ -72,9 +73,19 @@ func (h *Handler) Handle(
 	}
 
 	t := parsed.In(h.timezone)
-
-	return nil, Output{
+	output := Output{
 		Timestamp: t.Format(time.RFC3339),
 		Date:      t.Format("2006-01-02"),
-	}, nil
+	}
+
+	inputDisplay := input.Date
+	if inputDisplay == "" {
+		inputDisplay = "(empty)"
+	}
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: fmt.Sprintf("Parsed %q → %s", inputDisplay, output.Timestamp),
+		}},
+	}, output, nil
 }