Detailed changes
@@ -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
@@ -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
)
@@ -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=
@@ -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
}
@@ -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
@@ -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
}
@@ -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.
@@ -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
+}
@@ -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
+}
@@ -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 = ¬eStr
- }
-
- 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
-}
@@ -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 = ¬eVal
+ 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
@@ -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.
@@ -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
+}
@@ -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
-}
@@ -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
}
@@ -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
+}