diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..4aab2ba9edcbe9962003168930fe1d7180bb0eb1 --- /dev/null +++ b/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 ` and `SPDX-License-Identifier: AGPL-3.0-or-later` for source files. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d115dc36024f1cf8b263b9fc0b7447b585bb14aa --- /dev/null +++ b/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 \ No newline at end of file diff --git a/cmd/planning-mcp-server/main.go b/cmd/planning-mcp-server/main.go new file mode 100644 index 0000000000000000000000000000000000000000..0182c85c2fe9bd43873ea4dd147845790056fa7b --- /dev/null +++ b/cmd/planning-mcp-server/main.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 + } +} diff --git a/go.mod b/go.mod index 69460f4e9c3cbaf509d456cf6471197d57704fdb..274d86146d9460ab9eaae6523d8654f833a3ab34 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..de1012281aec02536d72138034ec3c39f1af322c --- /dev/null +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..1155c0d8e1321757d991d1828ca0b7da039adee4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..7675e35d1c1108e8c3c6e2d8f8399af72bbb1417 --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000000000000000000000000000000000000..4e5635bef33ed315baf7425f9e5f6207150f7dd6 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/planning/manager.go b/internal/planning/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..78e0e24a313d9739a37d347603627b5d89b55231 --- /dev/null +++ b/internal/planning/manager.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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"` +} diff --git a/internal/planning/types.go b/internal/planning/types.go new file mode 100644 index 0000000000000000000000000000000000000000..b564e1b37a5b6a4af97c426ef3ec6baa823629a3 --- /dev/null +++ b/internal/planning/types.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/justfile b/justfile new file mode 100644 index 0000000000000000000000000000000000000000..ae9815b0615c04fa2ddaed4fb794e44adc3c9d36 --- /dev/null +++ b/justfile @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Amolith +# +# 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 diff --git a/planning-mcp-server.toml b/planning-mcp-server.toml new file mode 100644 index 0000000000000000000000000000000000000000..2dc6620ddd623a28f8c8bed289abf5b4d5bdb602 --- /dev/null +++ b/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