diff --git a/.golangci.yaml b/.golangci.yaml index 5ec9c0bbd9de44c0b987827cd2bdff8b5bc7f6c3..e7b91b6bf1c6f199827cb8efb449183d9dc798b5 100644 --- a/.golangci.yaml +++ b/.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 diff --git a/go.mod b/go.mod index 9170c8342ddeb361e37ffa042c2732f299247960..780316277f418dbbef91cf6c31b048464cac8edc 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index a96b9cd5eaa4f4db84fce2b5c37768730cba1859..318e9e821b12d28823e6d595271ba4d3dab5886b 100644 --- a/go.sum +++ b/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= diff --git a/tools/areas/handler.go b/tools/areas/handler.go index 6f5cec5f8c00ccc7344f257bdef4a28466a04a8c..b117576cc749433092a49c859633da8e32e16943 100644 --- a/tools/areas/handler.go +++ b/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 } diff --git a/tools/areas/prose.go b/tools/areas/prose.go index 957bbac27eea0231a61d586d7b159350f9bccc6d..17506cfb330a4917256e1960579d4a86ae21f71b 100644 --- a/tools/areas/prose.go +++ b/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 diff --git a/tools/habits/handler.go b/tools/habits/handler.go index 177262d9b2e25f05d305790878a7355f1ca379e8..cf799430f5ff09d0cc8ccf264234710e362b4498 100644 --- a/tools/habits/handler.go +++ b/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 } diff --git a/tools/habits/prose.go b/tools/habits/prose.go index f0cd1371611a695d0cbfeac88f66497d43a75383..b8a7bae840547c8d072f859d85122394b5f7c883 100644 --- a/tools/habits/prose.go +++ b/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. diff --git a/tools/habits/types.go b/tools/habits/types.go new file mode 100644 index 0000000000000000000000000000000000000000..6e76158b5464506b8af1a5b8c6ed017475bd61db --- /dev/null +++ b/tools/habits/types.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/tools/shared/shared.go b/tools/shared/shared.go index a62f0d87ade9ac3d7802a30701acd3df6a6870eb..308f0e9706555878724441884ad1417589e25b90 100644 --- a/tools/shared/shared.go +++ b/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 +} diff --git a/tools/tasks/fields.go b/tools/tasks/fields.go deleted file mode 100644 index 8f9dcb2933793546cc44b27a0ecc53a248d7863e..0000000000000000000000000000000000000000 --- a/tools/tasks/fields.go +++ /dev/null @@ -1,434 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// 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 -} diff --git a/tools/tasks/handler.go b/tools/tasks/handler.go index 9093826466222d177d23ced94a0f1fa282d59984..b784e27d5da5c765a94850243e8489f5b6ff66d4 100644 --- a/tools/tasks/handler.go +++ b/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 = ¬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 diff --git a/tools/tasks/prose.go b/tools/tasks/prose.go index 58530b2b551bb62d941059ba097a838cb510ff6e..8be1780007c378d9b14876e858b570a4c8d2b8ce 100644 --- a/tools/tasks/prose.go +++ b/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. diff --git a/tools/tasks/types.go b/tools/tasks/types.go new file mode 100644 index 0000000000000000000000000000000000000000..4761d9c2c42ee3ce02f75a9ac15d4e5601fc4646 --- /dev/null +++ b/tools/tasks/types.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/tools/tasks/validation.go b/tools/tasks/validation.go deleted file mode 100644 index 5dac928dd847b55b34a942d4955ef8064350c4e1..0000000000000000000000000000000000000000 --- a/tools/tasks/validation.go +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// 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 -} diff --git a/tools/timestamp/handler.go b/tools/timestamp/handler.go index a0243bba0956494caeee43fac42ac59a70ff2a02..d77983cdf190ae59a635ec196c6d7cdfc4d08c96 100644 --- a/tools/timestamp/handler.go +++ b/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 } diff --git a/tools/timestamp/types.go b/tools/timestamp/types.go new file mode 100644 index 0000000000000000000000000000000000000000..87393fc4a335466befc5f6a4ebbd6a9707e1360b --- /dev/null +++ b/tools/timestamp/types.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}