implement the thing

Amolith created

Change summary

AGENTS.md                       | 101 ++++++++++
README.md                       | 183 ++++++++++++++++++
cmd/planning-mcp-server/main.go | 236 ++++++++++++++++++++++++
go.mod                          |  47 ++++
go.sum                          | 114 +++++++++++
internal/config/config.go       |  88 +++++++++
internal/config/loader.go       |  91 +++++++++
internal/mcp/server.go          | 342 +++++++++++++++++++++++++++++++++++
internal/planning/manager.go    | 185 ++++++++++++++++++
internal/planning/types.go      |  98 ++++++++++
justfile                        |  61 ++++++
planning-mcp-server.toml        |  14 +
12 files changed, 1,560 insertions(+)

Detailed changes

AGENTS.md 🔗

@@ -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.

README.md 🔗

@@ -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>

cmd/planning-mcp-server/main.go 🔗

@@ -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
+	}
+}

go.mod 🔗

@@ -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
+)

go.sum 🔗

@@ -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=

internal/config/config.go 🔗

@@ -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
+}

internal/config/loader.go 🔗

@@ -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
+}

internal/mcp/server.go 🔗

@@ -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
+}

internal/planning/manager.go 🔗

@@ -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"`
+}

internal/planning/types.go 🔗

@@ -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
+}

justfile 🔗

@@ -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

planning-mcp-server.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