refactor(sdk): migrate to official MCP Go SDK

Amolith created

- Replace mark3labs/mcp-go with modelcontextprotocol/go-sdk
- Add typed Input/Output structs for all tool handlers
- Move validation logic to go-lunatask client (ParsePriority, etc.)
- Update prose to reference "resources" instead of "tools"

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

Change summary

.golangci.yaml             |  21 +
go.mod                     |  41 +++
go.sum                     | 127 ++++++++++-
tools/areas/handler.go     |  70 ++++-
tools/areas/prose.go       |   6 
tools/habits/handler.go    | 124 ++++++-----
tools/habits/prose.go      |  12 
tools/habits/types.go      |  20 +
tools/shared/shared.go     |  59 ++++
tools/tasks/fields.go      | 434 ---------------------------------------
tools/tasks/handler.go     | 444 +++++++++++++++++----------------------
tools/tasks/prose.go       |  23 -
tools/tasks/types.go       |  90 ++++++++
tools/tasks/validation.go  | 187 ----------------
tools/timestamp/handler.go |  35 --
tools/timestamp/types.go   |  17 +
16 files changed, 683 insertions(+), 1,027 deletions(-)

Detailed changes

.golangci.yaml 🔗

@@ -124,11 +124,21 @@ linters:
     exhaustruct:
       exclude:
         # External types where zero values are intentional by library design
-        - github.com/mark3labs/mcp-go/server.Hooks
-        - github.com/mark3labs/mcp-go/mcp.CallToolResult
-        - github.com/mark3labs/mcp-go/mcp.TextContent
+        - github.com/modelcontextprotocol/go-sdk/mcp.Implementation
+        - github.com/modelcontextprotocol/go-sdk/mcp.Tool
+        - github.com/modelcontextprotocol/go-sdk/mcp.Resource
+        - github.com/modelcontextprotocol/go-sdk/mcp.ReadResourceResult
+        - github.com/modelcontextprotocol/go-sdk/mcp.ResourceContents
+        - github.com/modelcontextprotocol/go-sdk/mcp.CallToolResult
+        - github.com/modelcontextprotocol/go-sdk/mcp.TextContent
         - git.secluded.site/go-lunatask.CreateTaskRequest
         - git.secluded.site/go-lunatask.UpdateTaskRequest
+        # Internal output types where some fields are optional
+        - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.CreateOutput
+        - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.UpdateOutput
+        - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.DeleteOutput
+        - git.sr.ht/~amolith/lunatask-mcp-server/tools/habits.TrackOutput
+        - git.sr.ht/~amolith/lunatask-mcp-server/tools/timestamp.Output
     tagliatelle:
       case:
         rules:
@@ -154,5 +164,10 @@ linters:
           - dupl              # Builder types differ but share method signatures
       - path: cmd/
         text: unused-parameter  # Cobra callback signatures can't be changed
+      - path: tools/
+        linters:
+          - err113            # Handler validation errors are descriptive by nature
+          - cyclop            # Apply options funcs have many branches but simple logic
+          - wrapcheck         # Returning errors to MCP doesn't need wrapping
       - path: internal/config/
         text: "0o700"           # Config directory permissions are intentional

go.mod 🔗

@@ -7,15 +7,50 @@ module git.sr.ht/~amolith/lunatask-mcp-server
 go 1.25.5
 
 require (
-	git.secluded.site/go-lunatask v0.1.0-rc1
+	git.secluded.site/go-lunatask v0.1.0-rc10
 	github.com/BurntSushi/toml v1.5.0
+	github.com/charmbracelet/huh v0.8.0
+	github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
+	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/ijt/go-anytime v1.9.2
-	github.com/mark3labs/mcp-go v0.23.1
+	github.com/mattn/go-isatty v0.0.20
+	github.com/modelcontextprotocol/go-sdk v1.2.0
+	github.com/spf13/cobra v1.10.2
+	github.com/zalando/go-keyring v0.2.6
 )
 
 require (
+	al.essio.dev/pkg/shellescape v1.5.1 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
+	github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
+	github.com/charmbracelet/bubbletea v1.3.10 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.10.1 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
+	github.com/danieljoos/wincred v1.2.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect
-	github.com/spf13/cast v1.7.1 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/oauth2 v0.30.0 // indirect
+	golang.org/x/sys v0.36.0 // indirect
+	golang.org/x/text v0.23.0 // indirect
 )

go.sum 🔗

@@ -1,36 +1,131 @@
-git.secluded.site/go-lunatask v0.1.0-rc1 h1:02np5gzm7f0D9uOcvq0/6qsLjREbyowQ++ZQTG9JNVA=
-git.secluded.site/go-lunatask v0.1.0-rc1/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc=
+al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
+al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
+git.secluded.site/go-lunatask v0.1.0-rc10 h1:KKkYNs/cipNjIlRPXAvpPm5QcWSuA3REcG8XZ8sALk4=
+git.secluded.site/go-lunatask v0.1.0-rc10/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ=
 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
+github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
+github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
+github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU=
 github.com/ijt/go-anytime v1.9.2/go.mod h1:egBT6FhVjNlXNHUN2wTPi6ILCNKXeeXFy04pWJjw/LI=
 github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d h1:LFOmpWrSbtolg0YqYC9hQjj5WSLtRGb6aZ3JAugLfgg=
 github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d/go.mod h1:112TOyA+aruNSUBlyBWlKBdLVYTdhjiO2CKD0j/URSU=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mark3labs/mcp-go v0.23.1 h1:RzTzZ5kJ+HxwnutKA4rll8N/pKV6Wh5dhCmiJUu5S9I=
-github.com/mark3labs/mcp-go v0.23.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
+github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
-github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI=
 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
+github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/areas/handler.go 🔗

@@ -2,20 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-// Package areas provides the list_areas_and_goals MCP tool.
+// Package areas provides the areas MCP resource for listing areas and goals.
 package areas
 
 import (
 	"context"
-	"fmt"
-	"strings"
+	"encoding/json"
 
-	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
 
 	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
 )
 
-// Handler handles area-related MCP tool calls.
+// ResourceURI is the URI for the areas resource.
+const ResourceURI = "lunatask://areas"
+
+// Handler handles area-related MCP resource requests.
 type Handler struct {
 	areas []shared.AreaProvider
 }
@@ -25,27 +27,57 @@ func NewHandler(areas []shared.AreaProvider) *Handler {
 	return &Handler{areas: areas}
 }
 
-// Handle handles the list_areas_and_goals tool call.
-func (h *Handler) Handle(
+// AreaInfo represents an area with its goals for JSON serialization.
+type AreaInfo struct {
+	Key   string     `json:"key"`
+	Name  string     `json:"name"`
+	ID    string     `json:"id"`
+	Goals []GoalInfo `json:"goals,omitempty"`
+}
+
+// GoalInfo represents a goal for JSON serialization.
+type GoalInfo struct {
+	Key  string `json:"key"`
+	Name string `json:"name"`
+	ID   string `json:"id"`
+}
+
+// HandleRead handles the areas resource read request.
+func (h *Handler) HandleRead(
 	_ context.Context,
-	_ mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	var builder strings.Builder
+	_ *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	areasInfo := make([]AreaInfo, 0, len(h.areas))
 
 	for _, area := range h.areas {
-		fmt.Fprintf(&builder, "- %s: %s\n", area.GetName(), area.GetID())
+		areaInfo := AreaInfo{
+			Key:   area.GetKey(),
+			Name:  area.GetName(),
+			ID:    area.GetID(),
+			Goals: make([]GoalInfo, 0, len(area.GetGoals())),
+		}
 
 		for _, goal := range area.GetGoals() {
-			fmt.Fprintf(&builder, "  - %s: %s\n", goal.GetName(), goal.GetID())
+			areaInfo.Goals = append(areaInfo.Goals, GoalInfo{
+				Key:  goal.GetKey(),
+				Name: goal.GetName(),
+				ID:   goal.GetID(),
+			})
 		}
+
+		areasInfo = append(areasInfo, areaInfo)
+	}
+
+	data, err := json.MarshalIndent(areasInfo, "", "  ")
+	if err != nil {
+		return nil, err
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: builder.String(),
-			},
-		},
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      ResourceURI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
 	}, nil
 }

tools/areas/prose.go 🔗

@@ -4,9 +4,9 @@
 
 package areas
 
-// ToolDescription describes the list_areas_and_goals tool for LLMs.
-const ToolDescription = `Lists all available areas and their associated goals with their IDs.
-Use this tool FIRST before creating or updating tasks to identify valid
+// ResourceDescription describes the areas resource for LLMs.
+const ResourceDescription = `Lists all available areas and their associated goals with their IDs.
+Read this resource FIRST before creating or updating tasks to identify valid
 area_id and goal_id values. Areas represent broad categories of work,
 and goals are specific objectives within those areas. Each task must
 belong to an area and can optionally be associated with a goal within

tools/habits/handler.go 🔗

@@ -7,95 +7,107 @@ package habits
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
-	"strings"
 
 	"git.secluded.site/go-lunatask"
-	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
 
 	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
 )
 
 // Handler handles habit-related MCP tool calls.
 type Handler struct {
-	accessToken string
-	habits      []shared.HabitProvider
+	client *lunatask.Client
+	habits []shared.HabitProvider
 }
 
-// NewHandler creates a new habits Handler.
+// NewHandler creates a new habits Handler for tool operations.
 func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler {
 	return &Handler{
-		accessToken: accessToken,
-		habits:      habits,
+		client: lunatask.NewClient(accessToken),
+		habits: habits,
 	}
 }
 
-// HandleList handles the list_habits_and_activities tool call.
-func (h *Handler) HandleList(
-	_ context.Context,
-	_ mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	var builder strings.Builder
-
-	for _, habit := range h.habits {
-		fmt.Fprintf(&builder, "- %s: %s\n", habit.GetName(), habit.GetID())
-	}
+// ResourceHandler handles habit-related MCP resource requests.
+type ResourceHandler struct {
+	habits []shared.HabitProvider
+}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: builder.String(),
-			},
-		},
-	}, nil
+// NewResourceHandler creates a new habits ResourceHandler for resource reads.
+func NewResourceHandler(habits []shared.HabitProvider) *ResourceHandler {
+	return &ResourceHandler{habits: habits}
 }
 
 // HandleTrack handles the track_habit_activity tool call.
-//
-//nolint:wrapcheck // ReportError returns nil for error
 func (h *Handler) HandleTrack(
 	ctx context.Context,
-	request mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	habitID, ok := request.Params.Arguments["habit_id"].(string)
-	if !ok || habitID == "" {
-		return shared.ReportError("Missing or invalid required argument: habit_id")
+	_ *mcp.CallToolRequest,
+	input TrackInput,
+) (*mcp.CallToolResult, TrackOutput, error) {
+	// Resolve habit by ID or key
+	habit := shared.FindHabit(h.habits, input.HabitID)
+	if habit == nil {
+		return nil, TrackOutput{}, fmt.Errorf("habit not found: %s", input.HabitID)
 	}
 
-	performedOnStr, ok := request.Params.Arguments["performed_on"].(string)
-	if !ok || performedOnStr == "" {
-		return shared.ReportError("Missing or invalid required argument: performed_on")
+	performedOn, err := lunatask.ParseDate(input.PerformedOn)
+	if err != nil {
+		return nil, TrackOutput{}, fmt.Errorf(
+			"invalid format for performed_on %q: must be YYYY-MM-DD",
+			input.PerformedOn,
+		)
 	}
 
-	performedOn, err := lunatask.ParseDate(performedOnStr)
+	resp, err := h.client.TrackHabitActivity(ctx, habit.GetID(), &lunatask.TrackHabitActivityRequest{
+		PerformedOn: performedOn,
+	})
 	if err != nil {
-		return shared.ReportError(fmt.Sprintf(
-			"Invalid format for performed_on: '%s'. Must be YYYY-MM-DD.",
-			performedOnStr,
-		))
+		return nil, TrackOutput{}, fmt.Errorf("failed to track habit activity: %w", err)
 	}
 
-	client := lunatask.NewClient(h.accessToken)
-	habitRequest := &lunatask.TrackHabitActivityRequest{
-		PerformedOn: performedOn,
+	return nil, TrackOutput{
+		Status:  resp.Status,
+		Message: resp.Message,
+	}, nil
+}
+
+// ResourceURI is the URI for the habits resource.
+const ResourceURI = "lunatask://habits"
+
+// HabitInfo represents a habit for JSON serialization.
+type HabitInfo struct {
+	Key  string `json:"key"`
+	Name string `json:"name"`
+	ID   string `json:"id"`
+}
+
+// HandleRead handles the habits resource read request.
+func (h *ResourceHandler) HandleRead(
+	_ context.Context,
+	_ *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	habitsInfo := make([]HabitInfo, 0, len(h.habits))
+
+	for _, habit := range h.habits {
+		habitsInfo = append(habitsInfo, HabitInfo{
+			Key:  habit.GetKey(),
+			Name: habit.GetName(),
+			ID:   habit.GetID(),
+		})
 	}
 
-	resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
+	data, err := json.MarshalIndent(habitsInfo, "", "  ")
 	if err != nil {
-		return shared.ReportError(fmt.Sprintf("Failed to track habit activity: %v", err))
+		return nil, err
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: fmt.Sprintf(
-					"Habit activity tracked successfully. Status: %s, Message: %s",
-					resp.Status,
-					resp.Message,
-				),
-			},
-		},
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      ResourceURI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
 	}, nil
 }

tools/habits/prose.go 🔗

@@ -4,19 +4,19 @@
 
 package habits
 
-// ListToolDescription describes the list_habits_and_activities tool for LLMs.
-const ListToolDescription = `Lists all configured habits and their IDs for habit tracking.
-Use this tool FIRST before track_habit_activity to identify valid habit_id values.
-Shows habit names, descriptions, and unique identifiers needed for tracking activities.`
+// ResourceDescription describes the habits resource for LLMs.
+const ResourceDescription = `Lists all configured habits and their IDs for habit tracking.
+Read this resource FIRST before track_habit_activity to identify valid habit_id values.
+Shows habit names and unique identifiers needed for tracking activities.`
 
 // TrackToolDescription describes the track_habit_activity tool for LLMs.
 const TrackToolDescription = `Records completion of a habit activity in Lunatask.
-WORKFLOW: First use list_habits_and_activities to get valid habit_id,
+WORKFLOW: First read the habits resource to get valid habit_id,
 then use get_timestamp to format the performed_on date.`
 
 // ParamHabitID describes the habit_id parameter.
 const ParamHabitID = `ID of the habit to track activity for.
-Must be a valid habit_id from list_habits_and_activities tool.`
+Must be a valid habit_id from the habits resource.`
 
 // ParamPerformedOn describes the performed_on parameter.
 const ParamPerformedOn = `Timestamp when the habit was performed.

tools/habits/types.go 🔗

@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package habits
+
+// TrackInput is the input for the track_habit_activity tool.
+type TrackInput struct {
+	// HabitID is the ID of the habit to track.
+	// Must be a valid habit_id from the habits resource.
+	HabitID string `json:"habit_id" jsonschema:"required"`
+	// PerformedOn is the date when the habit was performed in YYYY-MM-DD format.
+	PerformedOn string `json:"performed_on" jsonschema:"required"`
+}
+
+// TrackOutput is the output for the track_habit_activity tool.
+type TrackOutput struct {
+	Status  string `json:"status"`  // Status of the tracking operation
+	Message string `json:"message"` // Human-readable message about the result
+}

tools/shared/shared.go 🔗

@@ -9,8 +9,6 @@ import (
 	"errors"
 	"fmt"
 	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
 )
 
 // ErrTimezoneNotConfigured is returned when the timezone config value is empty.
@@ -23,6 +21,7 @@ var ErrTimezoneNotConfigured = errors.New(
 type AreaProvider interface {
 	GetName() string
 	GetID() string
+	GetKey() string
 	GetGoals() []GoalProvider
 }
 
@@ -32,6 +31,7 @@ type AreaProvider interface {
 type GoalProvider interface {
 	GetName() string
 	GetID() string
+	GetKey() string
 }
 
 // HabitProvider defines the interface for accessing habit data.
@@ -40,6 +40,7 @@ type GoalProvider interface {
 type HabitProvider interface {
 	GetName() string
 	GetID() string
+	GetKey() string
 }
 
 // Config holds the necessary configuration for tool handlers.
@@ -50,14 +51,6 @@ type Config struct {
 	Habits      []HabitProvider
 }
 
-// ReportError creates an MCP error result.
-func ReportError(msg string) (*mcp.CallToolResult, error) {
-	return &mcp.CallToolResult{
-		IsError: true,
-		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
-	}, nil
-}
-
 // LoadLocation loads a timezone location string, returning a *time.Location or error.
 func LoadLocation(timezone string) (*time.Location, error) {
 	if timezone == "" {
@@ -71,3 +64,49 @@ func LoadLocation(timezone string) (*time.Location, error) {
 
 	return loc, nil
 }
+
+// FindArea finds an area by ID or key from the list of providers. Returns nil if not found.
+func FindArea(areas []AreaProvider, idOrKey string) AreaProvider {
+	for _, ap := range areas {
+		if ap.GetID() == idOrKey || ap.GetKey() == idOrKey {
+			return ap
+		}
+	}
+
+	return nil
+}
+
+// FindGoalInArea checks if a goal exists within an area by ID or key.
+// Returns true if found.
+func FindGoalInArea(area AreaProvider, idOrKey string) bool {
+	for _, goal := range area.GetGoals() {
+		if goal.GetID() == idOrKey || goal.GetKey() == idOrKey {
+			return true
+		}
+	}
+
+	return false
+}
+
+// GetGoalInArea finds a goal within an area by ID or key.
+// Returns nil if not found.
+func GetGoalInArea(area AreaProvider, idOrKey string) GoalProvider {
+	for _, goal := range area.GetGoals() {
+		if goal.GetID() == idOrKey || goal.GetKey() == idOrKey {
+			return goal
+		}
+	}
+
+	return nil
+}
+
+// FindHabit finds a habit by ID or key from the list of providers. Returns nil if not found.
+func FindHabit(habits []HabitProvider, idOrKey string) HabitProvider {
+	for _, hp := range habits {
+		if hp.GetID() == idOrKey || hp.GetKey() == idOrKey {
+			return hp
+		}
+	}
+
+	return nil
+}

tools/tasks/fields.go 🔗

@@ -1,434 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package tasks
-
-import (
-	"fmt"
-
-	"git.secluded.site/go-lunatask"
-	"github.com/mark3labs/mcp-go/mcp"
-
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
-)
-
-// setCreatePriority sets priority on a create task request.
-func (h *Handler) setCreatePriority(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	priorityArg, exists := arguments["priority"]
-	if !exists || priorityArg == nil {
-		return nil
-	}
-
-	priorityStr, ok := priorityArg.(string)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for 'priority' argument: expected string.",
-		)
-
-		return result
-	}
-
-	translated, errResult := ParsePriority(priorityStr)
-	if errResult != nil {
-		return errResult
-	}
-
-	task.Priority = &translated
-
-	return nil
-}
-
-// setCreateEisenhower sets eisenhower on a create task request.
-func (h *Handler) setCreateEisenhower(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	eisenhowerArg, exists := arguments["eisenhower"]
-	if !exists || eisenhowerArg == nil {
-		return nil
-	}
-
-	eisenhowerStr, ok := eisenhowerArg.(string)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for 'eisenhower' argument: expected string.",
-		)
-
-		return result
-	}
-
-	translated, errResult := ParseEisenhower(eisenhowerStr)
-	if errResult != nil {
-		return errResult
-	}
-
-	task.Eisenhower = &translated
-
-	return nil
-}
-
-// setCreateMotivation sets motivation on a create task request.
-func (h *Handler) setCreateMotivation(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	motivationVal, exists := arguments["motivation"]
-	if !exists || motivationVal == nil {
-		return nil
-	}
-
-	motivation, ok := motivationVal.(string)
-	if !ok {
-		result, _ := shared.ReportError("'motivation' must be a string")
-
-		return result
-	}
-
-	if motivation == "" {
-		return nil
-	}
-
-	if errResult := ValidateMotivation(motivation); errResult != nil {
-		return errResult
-	}
-
-	task.Motivation = &motivation
-
-	return nil
-}
-
-// setCreateStatus sets status on a create task request.
-func (h *Handler) setCreateStatus(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	statusVal, exists := arguments["status"]
-	if !exists || statusVal == nil {
-		return nil
-	}
-
-	status, ok := statusVal.(string)
-	if !ok {
-		result, _ := shared.ReportError("'status' must be a string")
-
-		return result
-	}
-
-	if status == "" {
-		return nil
-	}
-
-	if errResult := ValidateStatus(status); errResult != nil {
-		return errResult
-	}
-
-	task.Status = &status
-
-	return nil
-}
-
-// setCreateEstimate sets estimate on a create task request.
-func (h *Handler) setCreateEstimate(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	estimateArg, exists := arguments["estimate"]
-	if !exists || estimateArg == nil {
-		return nil
-	}
-
-	estimateVal, ok := estimateArg.(float64)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for 'estimate' argument: expected number.",
-		)
-
-		return result
-	}
-
-	estimate := int(estimateVal)
-
-	if errResult := ValidateEstimate(estimate); errResult != nil {
-		return errResult
-	}
-
-	task.Estimate = &estimate
-
-	return nil
-}
-
-// setCreateScheduledOn sets scheduled_on on a create task request.
-func (h *Handler) setCreateScheduledOn(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	scheduledOnArg, exists := arguments["scheduled_on"]
-	if !exists {
-		return nil
-	}
-
-	scheduledOnStr, ok := scheduledOnArg.(string)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for scheduled_on argument: expected string.",
-		)
-
-		return result
-	}
-
-	if scheduledOnStr == "" {
-		return nil
-	}
-
-	date, err := lunatask.ParseDate(scheduledOnStr)
-	if err != nil {
-		result, _ := shared.ReportError(fmt.Sprintf(
-			"Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.",
-			scheduledOnStr,
-		))
-
-		return result
-	}
-
-	task.ScheduledOn = &date
-
-	return nil
-}
-
-// setCreateSource sets source fields on a create task request.
-func (h *Handler) setCreateSource(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) {
-	if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
-		task.Source = &sourceArg
-	}
-
-	if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
-		task.SourceID = &sourceIDArg
-	}
-}
-
-// setUpdateNote sets note on an update task request.
-func (h *Handler) setUpdateNote(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	noteArg, exists := arguments["note"]
-	if !exists {
-		return nil
-	}
-
-	noteStr, ok := noteArg.(string)
-	if !ok && noteArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for note argument: expected string.",
-		)
-
-		return result
-	}
-
-	if ok {
-		payload.Note = &noteStr
-	}
-
-	return nil
-}
-
-// setUpdateEstimate sets estimate on an update task request.
-func (h *Handler) setUpdateEstimate(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	estimateArg, exists := arguments["estimate"]
-	if !exists || estimateArg == nil {
-		return nil
-	}
-
-	estimateVal, ok := estimateArg.(float64)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for estimate argument: expected number.",
-		)
-
-		return result
-	}
-
-	estimate := int(estimateVal)
-
-	if errResult := ValidateEstimate(estimate); errResult != nil {
-		return errResult
-	}
-
-	payload.Estimate = &estimate
-
-	return nil
-}
-
-// setUpdatePriority sets priority on an update task request.
-func (h *Handler) setUpdatePriority(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	priorityArg, exists := arguments["priority"]
-	if !exists || priorityArg == nil {
-		return nil
-	}
-
-	priorityStr, ok := priorityArg.(string)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for 'priority' argument: expected string.",
-		)
-
-		return result
-	}
-
-	translated, errResult := ParsePriority(priorityStr)
-	if errResult != nil {
-		return errResult
-	}
-
-	payload.Priority = &translated
-
-	return nil
-}
-
-// setUpdateEisenhower sets eisenhower on an update task request.
-func (h *Handler) setUpdateEisenhower(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	eisenhowerArg, exists := arguments["eisenhower"]
-	if !exists || eisenhowerArg == nil {
-		return nil
-	}
-
-	eisenhowerStr, ok := eisenhowerArg.(string)
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for 'eisenhower' argument: expected string.",
-		)
-
-		return result
-	}
-
-	translated, errResult := ParseEisenhower(eisenhowerStr)
-	if errResult != nil {
-		return errResult
-	}
-
-	payload.Eisenhower = &translated
-
-	return nil
-}
-
-// setUpdateMotivation sets motivation on an update task request.
-func (h *Handler) setUpdateMotivation(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	motivationArg, exists := arguments["motivation"]
-	if !exists {
-		return nil
-	}
-
-	motivationStr, ok := motivationArg.(string)
-	if !ok && motivationArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for motivation argument: expected string.",
-		)
-
-		return result
-	}
-
-	if !ok {
-		return nil
-	}
-
-	if motivationStr != "" {
-		if errResult := ValidateMotivation(motivationStr); errResult != nil {
-			return errResult
-		}
-	}
-
-	payload.Motivation = &motivationStr
-
-	return nil
-}
-
-// setUpdateStatus sets status on an update task request.
-func (h *Handler) setUpdateStatus(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	statusArg, exists := arguments["status"]
-	if !exists {
-		return nil
-	}
-
-	statusStr, ok := statusArg.(string)
-	if !ok && statusArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for status argument: expected string.",
-		)
-
-		return result
-	}
-
-	if !ok {
-		return nil
-	}
-
-	if statusStr != "" {
-		if errResult := ValidateStatus(statusStr); errResult != nil {
-			return errResult
-		}
-	}
-
-	payload.Status = &statusStr
-
-	return nil
-}
-
-// setUpdateScheduledOn sets scheduled_on on an update task request.
-func (h *Handler) setUpdateScheduledOn(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	scheduledOnArg, exists := arguments["scheduled_on"]
-	if !exists {
-		return nil
-	}
-
-	scheduledOnStr, ok := scheduledOnArg.(string)
-	if !ok && scheduledOnArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for scheduled_on argument: expected string.",
-		)
-
-		return result
-	}
-
-	if !ok || scheduledOnStr == "" {
-		return nil
-	}
-
-	date, err := lunatask.ParseDate(scheduledOnStr)
-	if err != nil {
-		result, _ := shared.ReportError(fmt.Sprintf(
-			"Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.",
-			scheduledOnStr,
-		))
-
-		return result
-	}
-
-	payload.ScheduledOn = &date
-
-	return nil
-}

tools/tasks/handler.go 🔗

@@ -10,16 +10,19 @@ import (
 	"fmt"
 
 	"git.secluded.site/go-lunatask"
-	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
 
 	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
 )
 
+// MaxNameLength is the maximum allowed task name length.
+const MaxNameLength = 100
+
 // Handler handles task-related MCP tool calls.
 type Handler struct {
-	accessToken string
-	timezone    string
-	areas       []shared.AreaProvider
+	client   *lunatask.Client
+	timezone string
+	areas    []shared.AreaProvider
 }
 
 // NewHandler creates a new tasks Handler.
@@ -29,361 +32,294 @@ func NewHandler(
 	areas []shared.AreaProvider,
 ) *Handler {
 	return &Handler{
-		accessToken: accessToken,
-		timezone:    timezone,
-		areas:       areas,
+		client:   lunatask.NewClient(accessToken),
+		timezone: timezone,
+		areas:    areas,
 	}
 }
 
 // HandleCreate handles the create_task tool call.
-//
-//nolint:cyclop,funlen,wrapcheck // validation complexity; ReportError returns nil
 func (h *Handler) HandleCreate(
 	ctx context.Context,
-	request mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	arguments := request.Params.Arguments
-
+	_ *mcp.CallToolRequest,
+	input CreateInput,
+) (*mcp.CallToolResult, CreateOutput, error) {
 	if _, err := shared.LoadLocation(h.timezone); err != nil {
-		return shared.ReportError(err.Error())
+		return nil, CreateOutput{}, err
 	}
 
-	areaID, ok := arguments["area_id"].(string)
-	if !ok || areaID == "" {
-		return shared.ReportError("Missing or invalid required argument: area_id")
+	if len(input.Name) > MaxNameLength {
+		return nil, CreateOutput{}, fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
 	}
 
-	area := FindArea(h.areas, areaID)
+	area := shared.FindArea(h.areas, input.AreaID)
 	if area == nil {
-		return shared.ReportError("Area not found for given area_id")
-	}
-
-	goalID, errResult := h.validateGoalID(arguments, area)
-	if errResult != nil {
-		return errResult, nil
-	}
-
-	name, ok := arguments["name"].(string)
-	if !ok || name == "" {
-		return shared.ReportError("Missing or invalid required argument: name")
+		return nil, CreateOutput{}, fmt.Errorf("area not found: %s", input.AreaID)
 	}
 
-	if errResult := ValidateName(name); errResult != nil {
-		return errResult, nil
+	// Resolve goal key to ID if provided
+	var goalID string
+	if input.GoalID != nil && *input.GoalID != "" {
+		goal := shared.GetGoalInArea(area, *input.GoalID)
+		if goal == nil {
+			return nil, CreateOutput{}, fmt.Errorf(
+				"goal %s not found in area %s",
+				*input.GoalID,
+				area.GetName(),
+			)
+		}
+		goalID = goal.GetID()
 	}
 
-	task := lunatask.CreateTaskRequest{
-		Name:   name,
-		AreaID: &areaID,
-		GoalID: goalID,
-	}
+	builder := h.client.NewTask(input.Name).InArea(area.GetID())
 
-	if err := h.populateCreateFields(&task, arguments); err != nil {
-		return err, nil
+	if err := h.applyCreateOptions(builder, input, goalID); err != nil {
+		return nil, CreateOutput{}, err
 	}
 
-	client := lunatask.NewClient(h.accessToken)
-
-	response, err := client.CreateTask(ctx, &task)
+	task, err := builder.Create(ctx)
 	if err != nil {
-		return shared.ReportError(fmt.Sprintf("%v", err))
+		return nil, CreateOutput{}, fmt.Errorf("failed to create task: %w", err)
 	}
 
-	if response == nil {
-		return &mcp.CallToolResult{
-			Content: []mcp.Content{
-				mcp.TextContent{
-					Type: "text",
-					Text: "Task already exists (not an error).",
-				},
-			},
+	// Handle nil response (task already exists)
+	if task == nil {
+		return nil, CreateOutput{
+			Message: "Task already exists (not an error)",
 		}, nil
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: "Task created successfully with ID: " + response.ID,
-			},
-		},
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+	return nil, CreateOutput{
+		TaskID:   task.ID,
+		Message:  "Task created successfully",
+		DeepLink: deepLink,
 	}, nil
 }
 
 // HandleUpdate handles the update_task tool call.
-//
-//nolint:wrapcheck // ReportError returns nil
 func (h *Handler) HandleUpdate(
 	ctx context.Context,
-	request mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	arguments := request.Params.Arguments
-
-	taskID, ok := arguments["task_id"].(string)
-	if !ok || taskID == "" {
-		return shared.ReportError("Missing or invalid required argument: task_id")
-	}
-
+	_ *mcp.CallToolRequest,
+	input UpdateInput,
+) (*mcp.CallToolResult, UpdateOutput, error) {
 	if _, err := shared.LoadLocation(h.timezone); err != nil {
-		return shared.ReportError(err.Error())
+		return nil, UpdateOutput{}, err
 	}
 
-	updatePayload := lunatask.UpdateTaskRequest{}
+	builder := h.client.NewTaskUpdate(input.TaskID)
 
-	area, errResult := h.validateUpdateArea(arguments, &updatePayload)
-	if errResult != nil {
-		return errResult, nil
+	if err := h.applyUpdateOptions(builder, input); err != nil {
+		return nil, UpdateOutput{}, err
 	}
 
-	if errResult := h.validateUpdateGoal(arguments, area, &updatePayload); errResult != nil {
-		return errResult, nil
-	}
-
-	if errResult := h.validateUpdateName(arguments, &updatePayload); errResult != nil {
-		return errResult, nil
-	}
-
-	if err := h.populateUpdateFields(&updatePayload, arguments); err != nil {
-		return err, nil
-	}
-
-	client := lunatask.NewClient(h.accessToken)
-
-	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
+	task, err := builder.Update(ctx)
 	if err != nil {
-		return shared.ReportError(fmt.Sprintf("Failed to update task: %v", err))
+		return nil, UpdateOutput{}, fmt.Errorf("failed to update task: %w", err)
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: "Task updated successfully. ID: " + response.ID,
-			},
-		},
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+	return nil, UpdateOutput{
+		TaskID:   task.ID,
+		Message:  "Task updated successfully",
+		DeepLink: deepLink,
 	}, nil
 }
 
 // HandleDelete handles the delete_task tool call.
-//
-//nolint:wrapcheck // ReportError returns nil
 func (h *Handler) HandleDelete(
 	ctx context.Context,
-	request mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	taskID, ok := request.Params.Arguments["task_id"].(string)
-	if !ok || taskID == "" {
-		return shared.ReportError("Missing or invalid required argument: task_id")
-	}
-
-	client := lunatask.NewClient(h.accessToken)
-
-	_, err := client.DeleteTask(ctx, taskID)
+	_ *mcp.CallToolRequest,
+	input DeleteInput,
+) (*mcp.CallToolResult, DeleteOutput, error) {
+	_, err := h.client.DeleteTask(ctx, input.TaskID)
 	if err != nil {
-		return shared.ReportError(fmt.Sprintf("Failed to delete task: %v", err))
+		return nil, DeleteOutput{}, fmt.Errorf("failed to delete task: %w", err)
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: "Task deleted successfully.",
-			},
-		},
-	}, nil
+	return nil, DeleteOutput{Message: "Task deleted successfully"}, nil
 }
 
-// validateGoalID validates and returns the goal_id if provided.
-func (h *Handler) validateGoalID(
-	arguments map[string]any,
-	area shared.AreaProvider,
-) (*string, *mcp.CallToolResult) {
-	goalIDStr, exists := arguments["goal_id"].(string)
-	if !exists || goalIDStr == "" {
-		return nil, nil
-	}
-
-	if !FindGoalInArea(area, goalIDStr) {
-		result, _ := shared.ReportError(
-			"Goal not found in specified area for given goal_id",
-		)
-
-		return nil, result
-	}
-
-	return &goalIDStr, nil
+// Areas returns the list of configured areas for resource listing.
+func (h *Handler) Areas() []shared.AreaProvider {
+	return h.areas
 }
 
-// validateUpdateArea validates area_id for update and returns the area provider.
+// applyCreateOptions applies optional fields to a TaskBuilder.
 //
-//nolint:ireturn // returns interface by design
-func (h *Handler) validateUpdateArea(
-	arguments map[string]any,
-	payload *lunatask.UpdateTaskRequest,
-) (shared.AreaProvider, *mcp.CallToolResult) {
-	areaIDArg, exists := arguments["area_id"]
-	if !exists {
-		return nil, nil
+//nolint:funlen // each field handling is straightforward
+func (h *Handler) applyCreateOptions(builder *lunatask.TaskBuilder, input CreateInput, goalID string) error {
+	if goalID != "" {
+		builder.InGoal(goalID)
 	}
 
-	areaIDStr, ok := areaIDArg.(string)
-	if !ok && areaIDArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for area_id argument: expected string.",
-		)
-
-		return nil, result
+	if input.Note != nil {
+		builder.WithNote(*input.Note)
 	}
 
-	if !ok || areaIDStr == "" {
-		return nil, nil
+	if input.Estimate != nil {
+		builder.WithEstimate(*input.Estimate)
 	}
 
-	payload.AreaID = &areaIDStr
-	area := FindArea(h.areas, areaIDStr)
+	if input.Priority != nil {
+		p, err := lunatask.ParsePriority(*input.Priority)
+		if err != nil {
+			return fmt.Errorf("invalid priority: %w", err)
+		}
 
-	if area == nil {
-		result, _ := shared.ReportError("Area not found for given area_id: " + areaIDStr)
-
-		return nil, result
+		builder.Priority(p)
 	}
 
-	return area, nil
-}
+	if input.Motivation != nil {
+		m, err := lunatask.ParseMotivation(*input.Motivation)
+		if err != nil {
+			return fmt.Errorf("invalid motivation: %w", err)
+		}
 
-// validateUpdateGoal validates goal_id for update.
-func (h *Handler) validateUpdateGoal(
-	arguments map[string]any,
-	area shared.AreaProvider,
-	payload *lunatask.UpdateTaskRequest,
-) *mcp.CallToolResult {
-	goalIDArg, exists := arguments["goal_id"]
-	if !exists {
-		return nil
+		builder.WithMotivation(m)
 	}
 
-	goalIDStr, ok := goalIDArg.(string)
-	if !ok && goalIDArg != nil {
-		result, _ := shared.ReportError(
-			"Invalid type for goal_id argument: expected string.",
-		)
+	if input.Eisenhower != nil {
+		e, err := lunatask.ParseEisenhower(*input.Eisenhower)
+		if err != nil {
+			return fmt.Errorf("invalid eisenhower: %w", err)
+		}
 
-		return result
+		builder.WithEisenhower(e)
 	}
 
-	if !ok || goalIDStr == "" {
-		return nil
+	if input.Status != nil {
+		s, err := lunatask.ParseTaskStatus(*input.Status)
+		if err != nil {
+			return fmt.Errorf("invalid status: %w", err)
+		}
+
+		builder.WithStatus(s)
 	}
 
-	payload.GoalID = &goalIDStr
+	if input.ScheduledOn != nil && *input.ScheduledOn != "" {
+		date, err := lunatask.ParseDate(*input.ScheduledOn)
+		if err != nil {
+			return fmt.Errorf("invalid scheduled_on date: %w", err)
+		}
 
-	if area != nil && !FindGoalInArea(area, goalIDStr) {
-		result, _ := shared.ReportError(fmt.Sprintf(
-			"Goal not found in specified area '%s' for given goal_id: %s",
-			area.GetName(),
-			goalIDStr,
-		))
+		builder.ScheduledOn(date)
+	}
+
+	if input.Source != nil && *input.Source != "" {
+		sourceID := ""
+		if input.SourceID != nil {
+			sourceID = *input.SourceID
+		}
 
-		return result
+		builder.FromSource(*input.Source, sourceID)
 	}
 
 	return nil
 }
 
-// validateUpdateName validates and sets the name for update.
-func (h *Handler) validateUpdateName(
-	arguments map[string]any,
-	payload *lunatask.UpdateTaskRequest,
-) *mcp.CallToolResult {
-	nameArg := arguments["name"]
-	nameStr, ok := nameArg.(string)
+// applyUpdateOptions applies optional fields to a TaskUpdateBuilder.
+//
+//nolint:funlen,gocognit // each field handling is straightforward
+func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input UpdateInput) error {
+	var resolvedAreaID string
+	var resolvedGoalID string
 
-	if !ok {
-		result, _ := shared.ReportError(
-			"Invalid type for name argument: expected string.",
-		)
+	if input.AreaID != nil && *input.AreaID != "" {
+		area := shared.FindArea(h.areas, *input.AreaID)
+		if area == nil {
+			return fmt.Errorf("area not found: %s", *input.AreaID)
+		}
 
-		return result
-	}
+		resolvedAreaID = area.GetID()
+		builder.InArea(resolvedAreaID)
 
-	if errResult := ValidateName(nameStr); errResult != nil {
-		return errResult
+		// Validate and resolve goal if also being set
+		if input.GoalID != nil && *input.GoalID != "" {
+			goal := shared.GetGoalInArea(area, *input.GoalID)
+			if goal == nil {
+				return fmt.Errorf("goal %s not found in area %s", *input.GoalID, area.GetName())
+			}
+			resolvedGoalID = goal.GetID()
+		}
 	}
 
-	payload.Name = &nameStr
-
-	return nil
-}
-
-// populateCreateFields populates optional fields for task creation.
-func (h *Handler) populateCreateFields(
-	task *lunatask.CreateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	if noteVal, exists := arguments["note"].(string); exists {
-		task.Note = &noteVal
+	if input.GoalID != nil && *input.GoalID != "" {
+		if resolvedGoalID != "" {
+			// Already resolved above with area context
+			builder.InGoal(resolvedGoalID)
+		} else {
+			// No area context - try to resolve across all areas
+			for _, area := range h.areas {
+				if goal := shared.GetGoalInArea(area, *input.GoalID); goal != nil {
+					builder.InGoal(goal.GetID())
+					break
+				}
+			}
+		}
 	}
 
-	if errResult := h.setCreatePriority(task, arguments); errResult != nil {
-		return errResult
-	}
+	if input.Name != nil {
+		if len(*input.Name) > MaxNameLength {
+			return fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
+		}
 
-	if errResult := h.setCreateEisenhower(task, arguments); errResult != nil {
-		return errResult
+		builder.Name(*input.Name)
 	}
 
-	if errResult := h.setCreateMotivation(task, arguments); errResult != nil {
-		return errResult
+	if input.Note != nil {
+		builder.WithNote(*input.Note)
 	}
 
-	if errResult := h.setCreateStatus(task, arguments); errResult != nil {
-		return errResult
+	if input.Estimate != nil {
+		builder.WithEstimate(*input.Estimate)
 	}
 
-	if errResult := h.setCreateEstimate(task, arguments); errResult != nil {
-		return errResult
-	}
+	if input.Priority != nil {
+		p, err := lunatask.ParsePriority(*input.Priority)
+		if err != nil {
+			return fmt.Errorf("invalid priority: %w", err)
+		}
 
-	if errResult := h.setCreateScheduledOn(task, arguments); errResult != nil {
-		return errResult
+		builder.Priority(p)
 	}
 
-	h.setCreateSource(task, arguments)
+	if input.Motivation != nil {
+		m, err := lunatask.ParseMotivation(*input.Motivation)
+		if err != nil {
+			return fmt.Errorf("invalid motivation: %w", err)
+		}
 
-	return nil
-}
-
-// populateUpdateFields populates optional fields for task update.
-func (h *Handler) populateUpdateFields(
-	payload *lunatask.UpdateTaskRequest,
-	arguments map[string]any,
-) *mcp.CallToolResult {
-	if errResult := h.setUpdateNote(payload, arguments); errResult != nil {
-		return errResult
+		builder.WithMotivation(m)
 	}
 
-	if errResult := h.setUpdateEstimate(payload, arguments); errResult != nil {
-		return errResult
-	}
+	if input.Eisenhower != nil {
+		e, err := lunatask.ParseEisenhower(*input.Eisenhower)
+		if err != nil {
+			return fmt.Errorf("invalid eisenhower: %w", err)
+		}
 
-	if errResult := h.setUpdatePriority(payload, arguments); errResult != nil {
-		return errResult
+		builder.WithEisenhower(e)
 	}
 
-	if errResult := h.setUpdateEisenhower(payload, arguments); errResult != nil {
-		return errResult
-	}
+	if input.Status != nil {
+		s, err := lunatask.ParseTaskStatus(*input.Status)
+		if err != nil {
+			return fmt.Errorf("invalid status: %w", err)
+		}
 
-	if errResult := h.setUpdateMotivation(payload, arguments); errResult != nil {
-		return errResult
+		builder.WithStatus(s)
 	}
 
-	if errResult := h.setUpdateStatus(payload, arguments); errResult != nil {
-		return errResult
-	}
+	if input.ScheduledOn != nil && *input.ScheduledOn != "" {
+		date, err := lunatask.ParseDate(*input.ScheduledOn)
+		if err != nil {
+			return fmt.Errorf("invalid scheduled_on date: %w", err)
+		}
 
-	if errResult := h.setUpdateScheduledOn(payload, arguments); errResult != nil {
-		return errResult
+		builder.ScheduledOn(date)
 	}
 
 	return nil

tools/tasks/prose.go 🔗

@@ -6,15 +6,15 @@ package tasks
 
 // CreateToolDescription describes the create_task tool for LLMs.
 const CreateToolDescription = `Creates a new task in Lunatask.
-WORKFLOW: First use list_areas_and_goals to identify valid area_id and goal_id values,
+WORKFLOW: First read the areas resource to identify valid area_id and goal_id values,
 then use get_timestamp if scheduling the task. Only include optional parameters if
 the user indicates or hints at them. Try to interpret speech-to-text input that
 may not be entirely accurate.`
 
 // UpdateToolDescription describes the update_task tool for LLMs.
 const UpdateToolDescription = `Updates an existing task. Only provided fields will be updated.
-WORKFLOW: Use list_areas_and_goals first if changing area/goal,
-then get_timestamp if changing schedule. Only include parameters that are being changed.
+WORKFLOW: Read the areas resource first if changing area/goal,
+then use get_timestamp if changing schedule. Only include parameters that are being changed.
 Empty strings will clear existing values for text fields.`
 
 // DeleteToolDescription describes the delete_task tool for LLMs.
@@ -30,22 +30,22 @@ This must be a valid task ID from an existing task in Lunatask.`
 
 // ParamAreaID describes the area_id parameter for create.
 const ParamAreaID = `Area ID in which to create the task.
-Must be a valid area_id from list_areas_and_goals tool.`
+Must be a valid area_id from the areas resource.`
 
 // ParamUpdateAreaID describes the area_id parameter for update.
 const ParamUpdateAreaID = `New Area ID for the task.
-Must be a valid area_id from list_areas_and_goals tool.
+Must be a valid area_id from the areas resource.
 Only include if moving the task to a different area.
 If omitted, the task will remain in its current area.`
 
 // ParamGoalID describes the goal_id parameter for create.
 const ParamGoalID = `Optional goal ID to associate the task with.
-Must be a valid goal_id from list_areas_and_goals that belongs to the specified area.
+Must be a valid goal_id from the areas resource that belongs to the specified area.
 Only include if the task relates to a specific goal.`
 
 // ParamUpdateGoalID describes the goal_id parameter for update.
 const ParamUpdateGoalID = `New Goal ID for the task.
-Must be a valid goal_id from list_areas_and_goals that belongs to the task's area
+Must be a valid goal_id from the areas resource that belongs to the task's area
 (current or new). Only include if changing the goal association.`
 
 // ParamName describes the name parameter.
@@ -99,15 +99,14 @@ or empty string to clear. Only include if changing the motivation level.`
 
 // ParamEisenhower describes the eisenhower parameter for create.
 const ParamEisenhower = `Eisenhower Matrix quadrant for task prioritization.
-Valid values: 'both urgent and important', 'urgent, but not important',
-'important, but not urgent', 'neither urgent nor important', 'uncategorised'.
+Valid values: 'do-now' (urgent+important), 'delegate' (urgent, not important),
+'do-later' (important, not urgent), 'eliminate' (neither), 'uncategorized'.
 Only include for areas which the user has indicated follow the Eisenhower workflow.`
 
 // ParamUpdateEisenhower describes the eisenhower parameter for update.
 const ParamUpdateEisenhower = `New Eisenhower Matrix quadrant for task prioritization.
-Valid values: 'both urgent and important', 'urgent, but not important',
-'important, but not urgent', 'neither urgent nor important',
-'uncategorised' (clears the field).
+Valid values: 'do-now' (urgent+important), 'delegate' (urgent, not important),
+'do-later' (important, not urgent), 'eliminate' (neither), 'uncategorized'.
 Only include for areas which the user has indicated follow the Eisenhower workflow.`
 
 // ParamStatus describes the status parameter for create.

tools/tasks/types.go 🔗

@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package tasks
+
+// CreateInput is the input for the create_task tool.
+type CreateInput struct {
+	// AreaID is the area in which to create the task.
+	// Must be a valid area_id from the areas resource.
+	AreaID string `json:"area_id" jsonschema:"required"`
+	// GoalID is an optional goal to associate the task with.
+	// Must belong to the specified area.
+	GoalID *string `json:"goal_id,omitempty"`
+	// Name is the plain text task name using sentence case (max 100 characters).
+	Name string `json:"name" jsonschema:"required"`
+	// Note contains additional details using Markdown formatting.
+	Note *string `json:"note,omitempty"`
+	// Estimate is the estimated completion time in minutes (0-720, max 12 hours).
+	Estimate *int `json:"estimate,omitempty"`
+	// Priority is the task priority level.
+	Priority *string `json:"priority,omitempty"`
+	// Motivation indicates importance: must (critical), should (important), want (nice-to-have).
+	Motivation *string `json:"motivation,omitempty"`
+	// Eisenhower is the Eisenhower Matrix quadrant for prioritization.
+	// Valid: uncategorized, do-now, delegate, do-later, eliminate.
+	Eisenhower *string `json:"eisenhower,omitempty"`
+	// Status is the initial task status.
+	Status *string `json:"status,omitempty"`
+	// ScheduledOn is the scheduled date in YYYY-MM-DD format.
+	// Use get_timestamp tool first for natural language dates.
+	ScheduledOn *string `json:"scheduled_on,omitempty"`
+	// Source is the origin of the task (e.g. "home-assistant").
+	Source *string `json:"source,omitempty"`
+	// SourceID is the external ID from the source system.
+	SourceID *string `json:"source_id,omitempty"`
+}
+
+// CreateOutput is the output for the create_task tool.
+// When a task already exists (duplicate), TaskID will be empty and Message will indicate this.
+type CreateOutput struct {
+	TaskID   string `json:"task_id,omitempty"`   // ID of the created task (empty if duplicate)
+	Message  string `json:"message"`             // Human-readable result message
+	DeepLink string `json:"deep_link,omitempty"` // Lunatask deep link to the task
+}
+
+// UpdateInput is the input for the update_task tool.
+type UpdateInput struct {
+	// TaskID is the ID of the task to update.
+	TaskID string `json:"task_id" jsonschema:"required"`
+	// AreaID is the new area. Only include if moving the task.
+	AreaID *string `json:"area_id,omitempty"`
+	// GoalID is the new goal. Must belong to the task's area.
+	GoalID *string `json:"goal_id,omitempty"`
+	// Name is the new task name. Empty string clears the name.
+	Name *string `json:"name,omitempty"`
+	// Note is the new note content. Empty string clears the note.
+	Note *string `json:"note,omitempty"`
+	// Estimate is the new time estimate in minutes.
+	Estimate *int `json:"estimate,omitempty"`
+	// Priority is the new priority level.
+	Priority *string `json:"priority,omitempty"`
+	// Motivation is the new importance level. Empty string clears.
+	Motivation *string `json:"motivation,omitempty"`
+	// Eisenhower is the new Eisenhower quadrant.
+	// Valid: uncategorized, do-now, delegate, do-later, eliminate.
+	Eisenhower *string `json:"eisenhower,omitempty"`
+	// Status is the new task status. Empty string clears.
+	Status *string `json:"status,omitempty"`
+	// ScheduledOn is the new scheduled date in YYYY-MM-DD format.
+	ScheduledOn *string `json:"scheduled_on,omitempty"`
+}
+
+// UpdateOutput is the output for the update_task tool.
+type UpdateOutput struct {
+	TaskID   string `json:"task_id"`             // ID of the updated task
+	Message  string `json:"message"`             // Human-readable result message
+	DeepLink string `json:"deep_link,omitempty"` // Lunatask deep link to the task
+}
+
+// DeleteInput is the input for the delete_task tool.
+type DeleteInput struct {
+	// TaskID is the ID of the task to delete. This action cannot be undone.
+	TaskID string `json:"task_id" jsonschema:"required"`
+}
+
+// DeleteOutput is the output for the delete_task tool.
+type DeleteOutput struct {
+	Message string `json:"message"` // Human-readable result message
+}

tools/tasks/validation.go 🔗

@@ -1,187 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package tasks
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-
-	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
-)
-
-// Priority API values.
-const (
-	PriorityLowest  = -2
-	PriorityLow     = -1
-	PriorityNeutral = 0
-	PriorityHigh    = 1
-	PriorityHighest = 2
-)
-
-// Eisenhower API values.
-const (
-	EisenhowerUncategorised        = 0
-	EisenhowerUrgentAndImportant   = 1
-	EisenhowerUrgentNotImportant   = 2
-	EisenhowerImportantNotUrgent   = 3
-	EisenhowerNeitherUrgentNorImpt = 4
-)
-
-// MaxNameLength is the maximum allowed task name length.
-const MaxNameLength = 100
-
-// MaxEstimate is the maximum estimate in minutes (12 hours).
-const MaxEstimate = 720
-
-// priorityMap maps human-readable priority strings to API values.
-var priorityMap = map[string]int{ //nolint:gochecknoglobals // lookup table
-	"lowest":  PriorityLowest,
-	"low":     PriorityLow,
-	"neutral": PriorityNeutral,
-	"high":    PriorityHigh,
-	"highest": PriorityHighest,
-}
-
-// eisenhowerMap maps human-readable eisenhower strings to API values.
-var eisenhowerMap = map[string]int{ //nolint:gochecknoglobals // lookup table
-	"uncategorised":                EisenhowerUncategorised,
-	"both urgent and important":    EisenhowerUrgentAndImportant,
-	"urgent, but not important":    EisenhowerUrgentNotImportant,
-	"important, but not urgent":    EisenhowerImportantNotUrgent,
-	"neither urgent nor important": EisenhowerNeitherUrgentNorImpt,
-}
-
-// validMotivations are the allowed motivation values.
-var validMotivations = map[string]bool{ //nolint:gochecknoglobals // lookup table
-	"must":   true,
-	"should": true,
-	"want":   true,
-}
-
-// validStatuses are the allowed status values.
-var validStatuses = map[string]bool{ //nolint:gochecknoglobals // lookup table
-	"later":     true,
-	"next":      true,
-	"started":   true,
-	"waiting":   true,
-	"completed": true,
-}
-
-// ParsePriority parses a priority string and returns the API value.
-// Returns an error result if the priority is invalid.
-func ParsePriority(priorityStr string) (int, *mcp.CallToolResult) {
-	translated, isValid := priorityMap[strings.ToLower(priorityStr)]
-	if !isValid {
-		result, _ := shared.ReportError(fmt.Sprintf(
-			"Invalid 'priority' value: '%s'. "+
-				"Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.",
-			priorityStr,
-		))
-
-		return 0, result
-	}
-
-	return translated, nil
-}
-
-// ParseEisenhower parses an eisenhower string and returns the API value.
-// Returns an error result if the eisenhower value is invalid.
-func ParseEisenhower(eisenhowerStr string) (int, *mcp.CallToolResult) {
-	translated, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
-	if !isValid {
-		result, _ := shared.ReportError(fmt.Sprintf(
-			"Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', "+
-				"'both urgent and important', 'urgent, but not important', "+
-				"'important, but not urgent', 'neither urgent nor important'.",
-			eisenhowerStr,
-		))
-
-		return 0, result
-	}
-
-	return translated, nil
-}
-
-// ValidateMotivation checks if a motivation value is valid.
-// Returns an error result if invalid.
-func ValidateMotivation(motivation string) *mcp.CallToolResult {
-	if motivation != "" && !validMotivations[motivation] {
-		result, _ := shared.ReportError(
-			"'motivation' must be one of 'must', 'should', or 'want'",
-		)
-
-		return result
-	}
-
-	return nil
-}
-
-// ValidateStatus checks if a status value is valid.
-// Returns an error result if invalid.
-func ValidateStatus(status string) *mcp.CallToolResult {
-	if status != "" && !validStatuses[status] {
-		result, _ := shared.ReportError(
-			"'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'",
-		)
-
-		return result
-	}
-
-	return nil
-}
-
-// ValidateEstimate checks if an estimate value is within valid range.
-// Returns an error result if invalid.
-func ValidateEstimate(estimate int) *mcp.CallToolResult {
-	if estimate < 0 || estimate > MaxEstimate {
-		result, _ := shared.ReportError(
-			"'estimate' must be between 0 and 720 minutes",
-		)
-
-		return result
-	}
-
-	return nil
-}
-
-// ValidateName checks if a task name is valid.
-// Returns an error result if invalid.
-func ValidateName(name string) *mcp.CallToolResult {
-	if len(name) > MaxNameLength {
-		result, _ := shared.ReportError("'name' must be 100 characters or fewer")
-
-		return result
-	}
-
-	return nil
-}
-
-// FindArea finds an area by ID from the list of providers.
-// Returns nil if not found.
-//
-//nolint:ireturn // returns interface by design
-func FindArea(areas []shared.AreaProvider, areaID string) shared.AreaProvider {
-	for _, ap := range areas {
-		if ap.GetID() == areaID {
-			return ap
-		}
-	}
-
-	return nil
-}
-
-// FindGoalInArea checks if a goal exists within an area.
-// Returns true if found.
-func FindGoalInArea(area shared.AreaProvider, goalID string) bool {
-	for _, goal := range area.GetGoals() {
-		if goal.GetID() == goalID {
-			return true
-		}
-	}
-
-	return false
-}

tools/timestamp/handler.go 🔗

@@ -12,7 +12,7 @@ import (
 	"time"
 
 	"github.com/ijt/go-anytime"
-	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
 
 	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
 )
@@ -28,37 +28,24 @@ func NewHandler(timezone string) *Handler {
 }
 
 // Handle handles the get_timestamp tool call.
-//
-//nolint:wrapcheck // ReportError returns nil for error
 func (h *Handler) Handle(
 	_ context.Context,
-	request mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
-	natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
-	if !ok || natLangDate == "" {
-		return shared.ReportError(
-			"Missing or invalid required argument: natural_language_date",
-		)
-	}
-
+	_ *mcp.CallToolRequest,
+	input Input,
+) (*mcp.CallToolResult, Output, error) {
 	loc, err := shared.LoadLocation(h.timezone)
 	if err != nil {
-		return shared.ReportError(err.Error())
+		return nil, Output{}, err
 	}
 
-	parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
+	parsedTime, err := anytime.Parse(input.NaturalLanguageDate, time.Now().In(loc))
 	if err != nil {
-		return shared.ReportError(
-			fmt.Sprintf("Could not parse natural language date: %v", err),
+		return nil, Output{}, fmt.Errorf(
+			"could not parse natural language date %q: %w",
+			input.NaturalLanguageDate,
+			err,
 		)
 	}
 
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: parsedTime.Format(time.RFC3339),
-			},
-		},
-	}, nil
+	return nil, Output{Timestamp: parsedTime.Format(time.RFC3339)}, nil
 }

tools/timestamp/types.go 🔗

@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package timestamp
+
+// Input is the input for the get_timestamp tool.
+type Input struct {
+	// NaturalLanguageDate is a natural language date/time expression to parse
+	// (e.g. "tomorrow at 3pm", "next Monday", "in 2 hours").
+	NaturalLanguageDate string `json:"natural_language_date" jsonschema:"required"`
+}
+
+// Output is the output for the get_timestamp tool.
+type Output struct {
+	Timestamp string `json:"timestamp"` // RFC3339 formatted timestamp
+}