Detailed changes
@@ -0,0 +1,101 @@
+# AGENTS.md
+
+This file provides guidance to AI coding assistants when working with code in this repository.
+
+## Development Commands
+
+This project uses `just` as the build tool. Essential commands:
+
+```bash
+# Full development workflow (default)
+just
+
+# Individual commands
+just fmt # Format Go code with gofumpt
+just lint # Run golangci-lint
+just staticcheck # Static analysis
+just test # Run tests with go test -v ./...
+just vuln # Check for vulnerabilities with govulncheck
+just reuse # Check license/copyright headers
+
+# Building and running
+just build # Build binary
+just run # Run server directly
+just install # Install to GOPATH/bin
+```
+
+The project requires license headers (SPDX format) on all source files and uses REUSE for compliance checking.
+
+## Architecture Overview
+
+### Core Components
+
+**MCP Server Architecture**: The server follows a clean layered architecture:
+- `cmd/planning-mcp-server/main.go`: CLI entry point with Cobra, supports both STDIO and HTTP modes
+- `internal/mcp/server.go`: MCP protocol wrapper that bridges MCP calls to planning operations
+- `internal/planning/manager.go`: Core business logic with thread-safe in-memory storage
+- `internal/config/`: Configuration management with Viper, supports TOML files and env vars
+
+### Planning System Design
+
+**Task Management**: Tasks use deterministic IDs generated via SHA256 hash of `title:description`, ensuring consistent IDs across sessions without persistence. This is critical - task IDs are not user-provided but generated automatically.
+
+**Thread Safety**: The planning manager uses `sync.RWMutex` for concurrent access. All public methods properly lock/unlock.
+
+**Status System**: Tasks use emoji indicators with specific meanings:
+- `☐` pending
+- `⟳` in_progress
+- `☑` completed
+- `☒` failed
+
+**Task List Legend**: The `get_tasks()` method includes a legend showing status indicators. The legend format is "Legend: ☐ pending ⟳ in progress ☑ completed" and only includes the failed icon (☒) if there are actually failed tasks in the current list.
+
+### MCP Tool Implementation
+
+The server exposes four MCP tools that map directly to planning manager methods:
+- `update_goal(goal: string)`: Sets overarching goal with length validation
+- `add_tasks(tasks: []TaskInput)`: Batch task creation with duplicate detection
+- `get_tasks()`: Returns markdown-formatted task list with legend and sorted by creation time
+- `update_task_status(task_id: string, status: string)`: Updates task status and returns full list
+
+### Configuration System
+
+Uses a three-tier config system (defaults → file → environment variables):
+- Server mode: `stdio` (default) or `http`
+- Planning limits: max tasks (100), max goal length (1000), max task length (500)
+- Environment variables prefixed with `PLANNING_` (e.g., `PLANNING_SERVER_MODE`)
+
+## Development Guidelines
+
+### Code Patterns
+
+**Error Handling**: All functions return descriptive errors. MCP handlers convert errors to `CallToolResult` with `IsError: true`.
+
+**Validation**: Input validation happens at multiple layers - MCP parameter parsing, planning manager limits, and config validation.
+
+**Logging**: Uses structured logging (slog) throughout. All operations log at appropriate levels with contextual fields.
+
+### Testing Approach
+
+The project structure suggests unit testing at the package level. When adding tests:
+- Test planning manager methods for concurrent access
+- Mock MCP requests for server handler testing
+- Test configuration loading and validation edge cases
+- Verify task ID generation is deterministic
+
+### Key Dependencies
+
+- `github.com/mark3labs/mcp-go`: MCP protocol implementation
+- `github.com/spf13/viper`: Configuration management
+- `github.com/spf13/cobra`: CLI framework
+- `github.com/charmbracelet/fang`: Enhanced CLI experience
+
+### Important Constraints
+
+**Stateless Design**: No persistent storage - all data is in-memory. This is intentional for the planning use case.
+
+**Deterministic IDs**: Task IDs must remain consistent. Never change the ID generation algorithm without migration strategy.
+
+**MCP Compliance**: All tool responses must follow MCP schema. Responses include both success messages and full task lists where appropriate.
+
+**SPDX Licensing**: All new files require SPDX headers. Use `SPDX-FileCopyrightText: Amolith <amolith@secluded.site>` and `SPDX-License-Identifier: AGPL-3.0-or-later` for source files.
@@ -0,0 +1,183 @@
+# planning-mcp-server
+
+A Model Context Protocol (MCP) server that provides planning tools for LLMs to thoroughly plan their actions before getting started and pivot during execution when needed.
+
+## Features
+
+The server provides four core tools that constitute a comprehensive planning workflow:
+
+- **`update_goal`**: Set or update the overarching goal for your planning session
+- **`add_tasks`**: Add one or more tasks to work on
+- **`get_tasks`**: Get current task list with status indicators and legend
+- **`update_task_status`**: Update the status of a specific task
+
+## Installation
+
+```bash
+go build -o planning-mcp-server ./cmd/planning-mcp-server
+```
+
+## Configuration
+
+Generate an example configuration file:
+
+```bash
+./planning-mcp-server --generate-config
+```
+
+This creates `planning-mcp-server.toml` with default settings:
+
+```toml
+[server]
+mode = 'stdio' # or 'http'
+host = 'localhost'
+port = 8080
+
+[logging]
+level = 'info' # debug, info, warn, error
+format = 'text' # text, json
+
+[planning]
+max_tasks = 100
+max_goal_length = 1000
+max_task_length = 500
+history_enabled = true
+```
+
+## Usage
+
+### STDIO Mode (Default)
+
+For use with MCP clients like Claude Desktop:
+
+```bash
+./planning-mcp-server --mode stdio
+```
+
+### HTTP Mode
+
+For web-based integrations:
+
+```bash
+./planning-mcp-server --mode http --port 8080
+```
+
+### CLI Options
+
+```bash
+./planning-mcp-server --help
+```
+
+- `--config, -c`: Configuration file path
+- `--mode, -m`: Server mode (stdio or http)
+- `--port, -p`: HTTP server port
+- `--host`: HTTP server host
+- `--log-level`: Log level (debug, info, warn, error)
+- `--generate-config`: Generate example configuration
+- `--version, -v`: Show version
+
+## Tool Examples
+
+### Setting a Goal
+
+```json
+{
+ "name": "update_goal",
+ "arguments": {
+ "goal": "Create a comprehensive MCP server for task planning and management"
+ }
+}
+```
+
+Response: `Goal "Create a comprehensive MCP server for task planning and management" saved! You probably want to add one or more tasks now.`
+
+### Adding Tasks
+
+```json
+{
+ "name": "add_tasks",
+ "arguments": {
+ "tasks": [
+ {
+ "title": "Set up project structure",
+ "description": "Create Go module, directories, and basic files"
+ },
+ {
+ "title": "Implement core planning logic",
+ "description": "Create Goal and Task data structures with deterministic IDs"
+ },
+ {
+ "title": "Build MCP server integration"
+ }
+ ]
+ }
+}
+```
+
+Response: `Tasks added successfully! Get started on your first one once you're ready, and call get_tasks frequently to remind yourself where you are in the process. Reminder that your overarching goal is "Create a comprehensive MCP server for task planning and management".`
+
+### Getting Task Status
+
+```json
+{
+ "name": "get_tasks",
+ "arguments": {}
+}
+```
+
+Response:
+```
+Legend: ☐ pending ⟳ in progress ☑ completed
+☐ Set up project structure [a1b2c3d4]
+ Create Go module, directories, and basic files
+☐ Implement core planning logic [e5f6g7h8]
+ Create Goal and Task data structures with deterministic IDs
+☐ Build MCP server integration [i9j0k1l2]
+```
+
+### Updating Task Status
+
+```json
+{
+ "name": "update_task_status",
+ "arguments": {
+ "task_id": "a1b2c3d4",
+ "status": "completed"
+ }
+}
+```
+
+Response:
+```
+Legend: ☐ pending ⟳ in progress ☑ completed
+☑ Set up project structure [a1b2c3d4]
+ Create Go module, directories, and basic files
+⟳ Implement core planning logic [e5f6g7h8]
+ Create Goal and Task data structures with deterministic IDs
+☐ Build MCP server integration [i9j0k1l2]
+```
+
+## Task Status Indicators
+
+- ☐ **pending**: Task is ready to be worked on
+- ⟳ **in_progress**: Task is currently being worked on
+- ☑ **completed**: Task has been finished successfully
+- ☒ **failed**: Task encountered an error or failed
+
+The task list includes a legend showing the status indicators. The failed icon (☒) is only shown in the legend if there are actually failed tasks in the list.
+
+## Task IDs
+
+Task IDs are deterministically generated based on the task title and description using SHA-256 hashing (8 hex characters). This ensures:
+
+- Same task content always gets the same ID
+- No collisions for different tasks
+- Consistent references across sessions
+
+## License
+
+AGPL-3.0-or-later
+
+## Author
+
+Amolith <amolith@secluded.site>
@@ -0,0 +1,236 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/charmbracelet/fang"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/spf13/cobra"
+
+ "git.sr.ht/~amolith/planning-mcp-server/internal/config"
+ "git.sr.ht/~amolith/planning-mcp-server/internal/mcp"
+ "git.sr.ht/~amolith/planning-mcp-server/internal/planning"
+)
+
+var version = "dev"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ // CLI flags
+ var (
+ configFile string
+ mode string
+ port int
+ host string
+ logLevel string
+ genConfig bool
+ showVersion bool
+ )
+
+ // Root command
+ rootCmd := &cobra.Command{
+ Use: "planning-mcp-server",
+ Short: "Planning MCP Server",
+ Long: `A Model Context Protocol (MCP) server that provides planning tools for LLMs.
+
+The server provides tools for goal setting, task management, and progress tracking:
+- update_goal: Set or update the overarching goal
+- add_tasks: Add one or more tasks to work on
+- get_tasks: Get current task list with status indicators
+- update_task_status: Update the status of a specific task`,
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if showVersion {
+ fmt.Printf("planning-mcp-server version %s\n", version)
+ return nil
+ }
+
+ if genConfig {
+ return generateExampleConfig()
+ }
+
+ return startServer(configFile, mode, port, host, logLevel)
+ },
+ }
+
+ // Add flags
+ rootCmd.Flags().StringVarP(&configFile, "config", "c", "", "Configuration file path")
+ rootCmd.Flags().StringVarP(&mode, "mode", "m", "", "Server mode: stdio or http (overrides config)")
+ rootCmd.Flags().IntVarP(&port, "port", "p", 0, "HTTP server port (overrides config)")
+ rootCmd.Flags().StringVar(&host, "host", "", "HTTP server host (overrides config)")
+ rootCmd.Flags().StringVar(&logLevel, "log-level", "", "Log level: debug, info, warn, error (overrides config)")
+ rootCmd.Flags().BoolVar(&genConfig, "generate-config", false, "Generate example configuration file")
+ rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
+
+ // Use Fang for enhanced CLI experience
+ ctx := context.Background()
+ return fang.Execute(ctx, rootCmd)
+}
+
+func generateExampleConfig() error {
+ configPath := "planning-mcp-server.toml"
+ if err := config.GenerateExampleConfig(configPath); err != nil {
+ return fmt.Errorf("failed to generate config: %w", err)
+ }
+ fmt.Printf("Generated example configuration: %s\n", configPath)
+ return nil
+}
+
+func startServer(configFile, mode string, port int, host, logLevel string) error {
+ // Load configuration
+ cfg, err := config.LoadConfig(configFile)
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ // Override config with CLI flags
+ if mode != "" {
+ cfg.Server.Mode = mode
+ }
+ if port != 0 {
+ cfg.Server.Port = port
+ }
+ if host != "" {
+ cfg.Server.Host = host
+ }
+ if logLevel != "" {
+ cfg.Logging.Level = logLevel
+ }
+
+ // Validate final config
+ if err := cfg.Validate(); err != nil {
+ return fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ // Setup logger
+ logger, err := setupLogger(cfg)
+ if err != nil {
+ return fmt.Errorf("failed to setup logger: %w", err)
+ }
+
+ logger.Info("Starting planning-mcp-server", "version", version, "mode", cfg.Server.Mode)
+
+ // Create context for graceful shutdown
+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ // Create planning manager
+ planner := planning.New(cfg, logger)
+
+ // Create MCP server
+ mcpServer, err := mcp.New(cfg, logger, planner)
+ if err != nil {
+ return fmt.Errorf("failed to create MCP server: %w", err)
+ }
+
+ // Start MCP server based on mode
+ switch cfg.Server.Mode {
+ case "stdio":
+ return runSTDIOServer(ctx, mcpServer, logger)
+ case "http":
+ return runHTTPServer(ctx, mcpServer, cfg, logger)
+ default:
+ return fmt.Errorf("unsupported server mode: %s", cfg.Server.Mode)
+ }
+}
+
+func setupLogger(cfg *config.Config) (*slog.Logger, error) {
+ // Parse log level
+ var level slog.Level
+ switch strings.ToLower(cfg.Logging.Level) {
+ case "debug":
+ level = slog.LevelDebug
+ case "info":
+ level = slog.LevelInfo
+ case "warn", "warning":
+ level = slog.LevelWarn
+ case "error":
+ level = slog.LevelError
+ default:
+ return nil, fmt.Errorf("invalid log level: %s", cfg.Logging.Level)
+ }
+
+ // Create handler based on format
+ var handler slog.Handler
+ switch strings.ToLower(cfg.Logging.Format) {
+ case "json":
+ handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
+ Level: level,
+ })
+ case "text":
+ handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: level,
+ })
+ default:
+ return nil, fmt.Errorf("invalid log format: %s", cfg.Logging.Format)
+ }
+
+ return slog.New(handler), nil
+}
+
+func runSTDIOServer(ctx context.Context, mcpServer *mcp.Server, logger *slog.Logger) error {
+ logger.Info("Starting STDIO MCP server")
+
+ // Serve with context using server.ServeStdio
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- server.ServeStdio(mcpServer.GetServer())
+ }()
+
+ // Wait for shutdown or error
+ select {
+ case <-ctx.Done():
+ logger.Info("Received shutdown signal")
+ return nil
+ case err := <-errCh:
+ if err != nil {
+ return fmt.Errorf("STDIO server error: %w", err)
+ }
+ return nil
+ }
+}
+
+func runHTTPServer(ctx context.Context, mcpServer *mcp.Server, cfg *config.Config, logger *slog.Logger) error {
+ addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
+ logger.Info("Starting HTTP MCP server", "address", addr)
+
+ // Create HTTP server
+ httpServer := server.NewStreamableHTTPServer(mcpServer.GetServer())
+
+ // Serve with context
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- httpServer.Start(addr)
+ }()
+
+ // Wait for shutdown or error
+ select {
+ case <-ctx.Done():
+ logger.Info("Received shutdown signal")
+ // Give server time to shutdown gracefully
+ time.Sleep(time.Second)
+ return nil
+ case err := <-errCh:
+ if err != nil {
+ return fmt.Errorf("HTTP server error: %w", err)
+ }
+ return nil
+ }
+}
@@ -1,3 +1,50 @@
module git.sr.ht/~amolith/planning-mcp-server
go 1.24.3
+
+require (
+ github.com/charmbracelet/fang v0.3.0
+ github.com/mark3labs/mcp-go v0.36.0
+ github.com/spf13/cobra v1.9.1
+ github.com/spf13/viper v1.20.1
+)
+
+require (
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/charmbracelet/colorprofile v0.3.1 // indirect
+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 // indirect
+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/fsnotify/fsnotify v1.8.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/invopop/jsonschema v0.13.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/mango v0.1.0 // indirect
+ github.com/muesli/mango-cobra v1.2.0 // indirect
+ github.com/muesli/mango-pflag v0.1.0 // indirect
+ github.com/muesli/roff v0.1.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sagikazarmark/locafero v0.7.0 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.12.0 // indirect
+ github.com/spf13/cast v1.7.1 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.9.0 // indirect
+ golang.org/x/sys v0.33.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
@@ -0,0 +1,114 @@
+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
+github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA=
+github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 h1:vq2enzx1Hr3UenVefpPEf+E2xMmqtZoSHhx8IE+V8ug=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+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/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
+github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+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/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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
+github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
+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/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
+github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
+github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
+github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
+github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+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/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/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
+github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
+github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
+github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
+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=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
+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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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=
@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+)
+
+// Config represents the server configuration
+type Config struct {
+ // Server settings
+ Server ServerConfig `mapstructure:"server" toml:"server"`
+
+ // Logging configuration
+ Logging LoggingConfig `mapstructure:"logging" toml:"logging"`
+
+ // Planning limits
+ Planning PlanningConfig `mapstructure:"planning" toml:"planning"`
+}
+
+// ServerConfig contains server-related settings
+type ServerConfig struct {
+ Mode string `mapstructure:"mode" toml:"mode"`
+ Host string `mapstructure:"host" toml:"host"`
+ Port int `mapstructure:"port" toml:"port"`
+}
+
+// LoggingConfig contains logging settings
+type LoggingConfig struct {
+ Level string `mapstructure:"level" toml:"level"`
+ Format string `mapstructure:"format" toml:"format"`
+}
+
+// PlanningConfig contains planning-related limits
+type PlanningConfig struct {
+ MaxTasks int `mapstructure:"max_tasks" toml:"max_tasks"`
+ MaxGoalLength int `mapstructure:"max_goal_length" toml:"max_goal_length"`
+ MaxTaskLength int `mapstructure:"max_task_length" toml:"max_task_length"`
+ HistoryEnabled bool `mapstructure:"history_enabled" toml:"history_enabled"`
+}
+
+// Default returns a Config with sensible defaults
+func Default() *Config {
+ return &Config{
+ Server: ServerConfig{
+ Mode: "stdio",
+ Host: "localhost",
+ Port: 8080,
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ Format: "text",
+ },
+ Planning: PlanningConfig{
+ MaxTasks: 100,
+ MaxGoalLength: 1000,
+ MaxTaskLength: 500,
+ HistoryEnabled: true,
+ },
+ }
+}
+
+// Validate validates the configuration
+func (c *Config) Validate() error {
+ if c.Server.Mode != "stdio" && c.Server.Mode != "http" {
+ return fmt.Errorf("server mode must be 'stdio' or 'http'")
+ }
+
+ if c.Server.Mode == "http" && (c.Server.Port <= 0 || c.Server.Port > 65535) {
+ return fmt.Errorf("server port must be between 1 and 65535")
+ }
+
+ if c.Planning.MaxTasks <= 0 {
+ return fmt.Errorf("max tasks must be positive")
+ }
+
+ if c.Planning.MaxGoalLength <= 0 {
+ return fmt.Errorf("max goal length must be positive")
+ }
+
+ if c.Planning.MaxTaskLength <= 0 {
+ return fmt.Errorf("max task length must be positive")
+ }
+
+ return nil
+}
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/viper"
+)
+
+// LoadConfig loads configuration from file, environment variables, and defaults
+func LoadConfig(configPath string) (*Config, error) {
+ cfg := Default()
+
+ v := viper.New()
+
+ // Set config file
+ if configPath != "" {
+ v.SetConfigFile(configPath)
+ } else {
+ // Look for config in current directory and common locations
+ v.SetConfigName("planning-mcp-server")
+ v.SetConfigType("toml")
+ v.AddConfigPath(".")
+ v.AddConfigPath("$HOME/.config/planning-mcp-server")
+ v.AddConfigPath("/etc/planning-mcp-server")
+ }
+
+ // Environment variables
+ v.SetEnvPrefix("PLANNING")
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+
+ // Read config file if it exists
+ if err := v.ReadInConfig(); err != nil {
+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+ // Config file not found is OK, we'll use defaults
+ }
+
+ // Unmarshal into config struct
+ if err := v.Unmarshal(cfg); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal config: %w", err)
+ }
+
+ // Validate configuration
+ if err := cfg.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ return cfg, nil
+}
+
+// GenerateExampleConfig generates an example configuration file
+func GenerateExampleConfig(path string) error {
+ cfg := Default()
+
+ // Ensure directory exists
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ v := viper.New()
+ v.SetConfigType("toml")
+
+ // Set all config values
+ v.Set("server.mode", cfg.Server.Mode)
+ v.Set("server.host", cfg.Server.Host)
+ v.Set("server.port", cfg.Server.Port)
+
+ v.Set("logging.level", cfg.Logging.Level)
+ v.Set("logging.format", cfg.Logging.Format)
+
+ v.Set("planning.max_tasks", cfg.Planning.MaxTasks)
+ v.Set("planning.max_goal_length", cfg.Planning.MaxGoalLength)
+ v.Set("planning.max_task_length", cfg.Planning.MaxTaskLength)
+ v.Set("planning.history_enabled", cfg.Planning.HistoryEnabled)
+
+ if err := v.WriteConfigAs(path); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+
+ return nil
+}
@@ -0,0 +1,342 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package mcp
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+
+ "git.sr.ht/~amolith/planning-mcp-server/internal/config"
+ "git.sr.ht/~amolith/planning-mcp-server/internal/planning"
+)
+
+// Server wraps the MCP server and implements planning tools
+type Server struct {
+ config *config.Config
+ logger *slog.Logger
+ planner *planning.Manager
+ server *server.MCPServer
+}
+
+// New creates a new MCP server
+func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*Server, error) {
+ if cfg == nil {
+ return nil, fmt.Errorf("config cannot be nil")
+ }
+ if logger == nil {
+ return nil, fmt.Errorf("logger cannot be nil")
+ }
+ if planner == nil {
+ return nil, fmt.Errorf("planner cannot be nil")
+ }
+
+ s := &Server{
+ config: cfg,
+ logger: logger,
+ planner: planner,
+ }
+
+ // Create MCP server
+ mcpServer := server.NewMCPServer(
+ "planning-mcp-server",
+ "1.0.0",
+ server.WithToolCapabilities(true),
+ )
+
+ // Register tools
+ s.registerTools(mcpServer)
+
+ s.server = mcpServer
+ return s, nil
+}
+
+// registerTools registers all planning tools
+func (s *Server) registerTools(mcpServer *server.MCPServer) {
+ // Register update_goal tool
+ updateGoalTool := mcp.NewTool("update_goal",
+ mcp.WithDescription("Set or update the overarching goal for your planning session"),
+ mcp.WithString("goal",
+ mcp.Required(),
+ mcp.Description("The goal text to set"),
+ ),
+ )
+ mcpServer.AddTool(updateGoalTool, s.handleUpdateGoal)
+
+ // Register add_tasks tool
+ addTasksTool := mcp.NewTool("add_tasks",
+ mcp.WithDescription("Add one or more tasks to work on"),
+ mcp.WithArray("tasks",
+ mcp.Required(),
+ mcp.Description("Array of tasks to add"),
+ mcp.Items(map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "title": map[string]any{
+ "type": "string",
+ "description": "Task title",
+ },
+ "description": map[string]any{
+ "type": "string",
+ "description": "Task description (optional)",
+ },
+ },
+ "required": []string{"title"},
+ }),
+ ),
+ )
+ mcpServer.AddTool(addTasksTool, s.handleAddTasks)
+
+ // Register get_tasks tool
+ getTasksTool := mcp.NewTool("get_tasks",
+ mcp.WithDescription("Get current task list with status indicators"),
+ )
+ mcpServer.AddTool(getTasksTool, s.handleGetTasks)
+
+ // Register update_task_status tool
+ updateTaskStatusTool := mcp.NewTool("update_task_status",
+ mcp.WithDescription("Update the status of a specific task"),
+ mcp.WithString("task_id",
+ mcp.Required(),
+ mcp.Description("The task ID to update"),
+ ),
+ mcp.WithString("status",
+ mcp.Required(),
+ mcp.Description("New status: pending, in_progress, completed, or failed"),
+ ),
+ )
+ mcpServer.AddTool(updateTaskStatusTool, s.handleUpdateTaskStatus)
+}
+
+// handleUpdateGoal handles the update_goal tool call
+func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ s.logger.Info("Received update_goal tool call")
+
+ // Extract parameters
+ arguments := request.GetArguments()
+ goal, ok := arguments["goal"].(string)
+ if !ok || goal == "" {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: goal parameter is required and must be a string",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Update goal
+ if err := s.planner.UpdateGoal(goal); err != nil {
+ s.logger.Error("Failed to update goal", "error", err)
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: fmt.Sprintf("Error updating goal: %v", err),
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal)
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: response,
+ },
+ },
+ }, nil
+}
+
+// handleAddTasks handles the add_tasks tool call
+func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ s.logger.Info("Received add_tasks tool call")
+
+ // Extract parameters
+ arguments := request.GetArguments()
+ tasksRaw, ok := arguments["tasks"]
+ if !ok {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: tasks parameter is required",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Convert to slice of interfaces
+ tasksSlice, ok := tasksRaw.([]any)
+ if !ok {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: tasks parameter must be an array",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Parse tasks
+ tasks := make([]planning.TaskInput, 0, len(tasksSlice))
+ for _, taskRaw := range tasksSlice {
+ taskMap, ok := taskRaw.(map[string]any)
+ if !ok {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: each task must be an object",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ title, ok := taskMap["title"].(string)
+ if !ok || title == "" {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: each task must have a non-empty title",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ description, _ := taskMap["description"].(string)
+
+ tasks = append(tasks, planning.TaskInput{
+ Title: title,
+ Description: description,
+ })
+ }
+
+ // Add tasks
+ if err := s.planner.AddTasks(tasks); err != nil {
+ s.logger.Error("Failed to add tasks", "error", err)
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: fmt.Sprintf("Error adding tasks: %v", err),
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Get current goal for reminder
+ goal := s.planner.GetGoal()
+ goalText := "your planning session"
+ if goal != nil {
+ goalText = fmt.Sprintf("\"%s\"", goal.Text)
+ }
+
+ response := fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready, and call `get_tasks` frequently to remind yourself where you are in the process. Reminder that your overarching goal is %s.", goalText)
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: response,
+ },
+ },
+ }, nil
+}
+
+// handleGetTasks handles the get_tasks tool call
+func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ s.logger.Info("Received get_tasks tool call")
+
+ taskList := s.planner.GetTasks()
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: taskList,
+ },
+ },
+ }, nil
+}
+
+// handleUpdateTaskStatus handles the update_task_status tool call
+func (s *Server) handleUpdateTaskStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ s.logger.Info("Received update_task_status tool call")
+
+ // Extract parameters
+ arguments := request.GetArguments()
+ taskID, ok := arguments["task_id"].(string)
+ if !ok || taskID == "" {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: task_id parameter is required and must be a string",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ statusStr, ok := arguments["status"].(string)
+ if !ok || statusStr == "" {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Error: status parameter is required and must be a string",
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Parse status
+ status := planning.ParseStatus(statusStr)
+
+ // Update task status
+ if err := s.planner.UpdateTaskStatus(taskID, status); err != nil {
+ s.logger.Error("Failed to update task status", "error", err)
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: fmt.Sprintf("Error updating task status: %v", err),
+ },
+ },
+ IsError: true,
+ }, nil
+ }
+
+ // Return full task list
+ taskList := s.planner.GetTasks()
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: taskList,
+ },
+ },
+ }, nil
+}
+
+// GetServer returns the underlying MCP server
+func (s *Server) GetServer() *server.MCPServer {
+ return s.server
+}
@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package planning
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ "git.sr.ht/~amolith/planning-mcp-server/internal/config"
+)
+
+// Manager handles planning operations
+type Manager struct {
+ config *config.Config
+ logger *slog.Logger
+
+ // Thread-safe storage
+ mu sync.RWMutex
+ goal *Goal
+ tasks map[string]*Task
+}
+
+// New creates a new planning manager
+func New(cfg *config.Config, logger *slog.Logger) *Manager {
+ return &Manager{
+ config: cfg,
+ logger: logger,
+ tasks: make(map[string]*Task),
+ }
+}
+
+// UpdateGoal sets or updates the overarching goal
+func (m *Manager) UpdateGoal(goalText string) error {
+ if len(goalText) > m.config.Planning.MaxGoalLength {
+ return fmt.Errorf("goal too long (max %d characters)", m.config.Planning.MaxGoalLength)
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.goal = &Goal{
+ Text: strings.TrimSpace(goalText),
+ UpdatedAt: time.Now(),
+ }
+
+ m.logger.Info("Goal updated", "goal", goalText)
+ return nil
+}
+
+// AddTasks adds one or more tasks
+func (m *Manager) AddTasks(tasks []TaskInput) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // Check task limits
+ if len(m.tasks)+len(tasks) > m.config.Planning.MaxTasks {
+ return fmt.Errorf("too many tasks (max %d)", m.config.Planning.MaxTasks)
+ }
+
+ addedTasks := make([]*Task, 0, len(tasks))
+
+ for _, taskInput := range tasks {
+ // Validate task input
+ if taskInput.Title == "" {
+ return fmt.Errorf("task title cannot be empty")
+ }
+
+ if len(taskInput.Title) > m.config.Planning.MaxTaskLength {
+ return fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
+ }
+
+ if len(taskInput.Description) > m.config.Planning.MaxTaskLength {
+ return fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
+ }
+
+ // Create task
+ task := NewTask(taskInput.Title, taskInput.Description)
+
+ // Check if task already exists (by ID)
+ if _, exists := m.tasks[task.ID]; exists {
+ m.logger.Warn("Task already exists, skipping", "id", task.ID, "title", task.Title)
+ continue
+ }
+
+ m.tasks[task.ID] = task
+ addedTasks = append(addedTasks, task)
+ }
+
+ m.logger.Info("Tasks added", "count", len(addedTasks))
+ return nil
+}
+
+// GetTasks returns a markdown-formatted list of tasks
+func (m *Manager) GetTasks() string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ if len(m.tasks) == 0 {
+ return "No tasks defined yet."
+ }
+
+ var lines []string
+
+ // Sort tasks by creation time for consistent output
+ taskList := make([]*Task, 0, len(m.tasks))
+ for _, task := range m.tasks {
+ taskList = append(taskList, task)
+ }
+
+ // Check if there are any failed tasks
+ hasFailed := false
+ for _, task := range taskList {
+ if task.Status == StatusFailed {
+ hasFailed = true
+ break
+ }
+ }
+
+ // Add legend
+ legend := "Legend: ☐ pending ⟳ in progress ☑ completed"
+ if hasFailed {
+ legend += " ☒ failed"
+ }
+ lines = append(lines, legend)
+
+ // Simple sort by creation time (newest first could be changed if needed)
+ for i := range len(taskList) {
+ for j := i + 1; j < len(taskList); j++ {
+ if taskList[i].CreatedAt.After(taskList[j].CreatedAt) {
+ taskList[i], taskList[j] = taskList[j], taskList[i]
+ }
+ }
+ }
+
+ for _, task := range taskList {
+ line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
+ lines = append(lines, line)
+ if task.Description != "" {
+ lines = append(lines, fmt.Sprintf(" %s", task.Description))
+ }
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// UpdateTaskStatus updates the status of a specific task
+func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ task, exists := m.tasks[taskID]
+ if !exists {
+ return fmt.Errorf("task not found: %s", taskID)
+ }
+
+ task.UpdateStatus(status)
+ m.logger.Info("Task status updated", "id", taskID, "status", status.String())
+ return nil
+}
+
+// GetGoal returns the current goal
+func (m *Manager) GetGoal() *Goal {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.goal
+}
+
+// GetTaskByID returns a task by its ID
+func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ task, exists := m.tasks[taskID]
+ return task, exists
+}
+
+// TaskInput represents input for creating a task
+type TaskInput struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+}
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package planning
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// TaskStatus represents the status of a task
+type TaskStatus int
+
+const (
+ StatusPending TaskStatus = iota
+ StatusInProgress
+ StatusCompleted
+ StatusFailed
+)
+
+// String returns the emoji representation of the task status
+func (s TaskStatus) String() string {
+ switch s {
+ case StatusPending:
+ return "☐"
+ case StatusInProgress:
+ return "⟳"
+ case StatusCompleted:
+ return "☑"
+ case StatusFailed:
+ return "☒"
+ default:
+ return "☐"
+ }
+}
+
+// ParseStatus converts a string status to TaskStatus enum
+func ParseStatus(status string) TaskStatus {
+ switch strings.ToLower(status) {
+ case "pending":
+ return StatusPending
+ case "in_progress":
+ return StatusInProgress
+ case "completed":
+ return StatusCompleted
+ case "failed":
+ return StatusFailed
+ default:
+ return StatusPending
+ }
+}
+
+// Goal represents the overarching goal
+type Goal struct {
+ Text string `json:"text"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Task represents a single task
+type Task struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Status TaskStatus `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// NewTask creates a new task with a deterministic ID
+func NewTask(title, description string) *Task {
+ // Generate deterministic ID based on title and description
+ id := generateTaskID(title, description)
+
+ return &Task{
+ ID: id,
+ Title: title,
+ Description: description,
+ Status: StatusPending,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+}
+
+// UpdateStatus updates the task status and timestamp
+func (t *Task) UpdateStatus(status TaskStatus) {
+ t.Status = status
+ t.UpdatedAt = time.Now()
+}
+
+// generateTaskID creates a deterministic 8-character ID based on task content
+func generateTaskID(title, description string) string {
+ content := fmt.Sprintf("%s:%s", title, description)
+ hash := sha256.Sum256([]byte(content))
+ return fmt.Sprintf("%x", hash[:4]) // 8 hex characters
+}
@@ -0,0 +1,61 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+GOOS := env("GOOS", `go env GOOS`)
+GOARCH := env("GOARCH", `go env GOARCH`)
+VERSION := `git describe --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g'`
+
+default: fmt lint staticcheck test vuln reuse
+
+fmt:
+ # Formatting all Go source code
+ go install mvdan.cc/gofumpt@latest
+ gofumpt -l -w .
+
+lint:
+ # Linting Go source code
+ golangci-lint run
+
+staticcheck:
+ # Performing static analysis
+ go install honnef.co/go/tools/cmd/staticcheck@latest
+ staticcheck ./...
+
+test:
+ # Running tests
+ go test -v ./...
+
+vuln:
+ # Checking for vulnerabilities
+ go install golang.org/x/vuln/cmd/govulncheck@latest
+ govulncheck ./...
+
+reuse:
+ # Linting licenses and copyright headers
+ reuse lint
+
+build:
+ # Building planning-mcp-server
+ CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go build -o planning-mcp-server -ldflags "-s -w -X main.version={{VERSION}}" ./cmd/planning-mcp-server
+
+install:
+ # Installing planning-mcp-server
+ CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go install -ldflags "-s -w -X main.version={{VERSION}}" ./cmd/planning-mcp-server
+
+run:
+ # Running planning-mcp-server
+ CGO_ENABLED=0 GOOS={{GOOS}} GOARCH={{GOARCH}} go run -ldflags "-s -w -X main.version={{VERSION}}" ./cmd/planning-mcp-server
+
+pack:
+ # Packing planning-mcp-server
+ upx --best -qo planning-mcp-server.min planning-mcp-server
+ mv planning-mcp-server.min planning-mcp-server
+
+clean:
+ # Removing build artifacts
+ rm -rf planning-mcp-server
+
+clean-all:
+ # Removing build artifacts and config.toml
+ rm -rf planning-mcp-server config.toml
@@ -0,0 +1,14 @@
+[logging]
+format = 'text'
+level = 'info'
+
+[planning]
+history_enabled = true
+max_goal_length = 1000
+max_task_length = 500
+max_tasks = 100
+
+[server]
+host = 'localhost'
+mode = 'stdio'
+port = 8080