# Directory Structure
```
├── .gitignore
├── .goreleaser.yml
├── CHANGELOG.md
├── cmd
│ └── mcp-todo-server
│ └── main.go
├── go.mod
├── go.sum
├── internal
│ ├── config
│ │ └── config.go
│ ├── mcp
│ │ ├── server.go
│ │ └── tools.go
│ └── services
│ ├── changelog
│ │ └── changelog.go
│ ├── file
│ │ └── file.go
│ └── todo
│ └── todo.go
├── LICENSE
├── README.md
└── TODO.md
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | build/
2 | dist/
3 | notes/
4 | tmp/
5 |
```
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
```yaml
1 | project_name: mcp-todo-server
2 |
3 | before:
4 | hooks:
5 | - go mod tidy
6 |
7 | builds:
8 | - env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - windows
13 | - darwin
14 | goarch:
15 | - amd64
16 | - arm64
17 | main: ./cmd/mcp-todo-server
18 | ldflags:
19 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
20 |
21 | archives:
22 | - format: tar.gz
23 | name_template: >-
24 | {{ .ProjectName }}_
25 | {{- .Version }}_
26 | {{- .Os }}_
27 | {{- .Arch }}
28 | format_overrides:
29 | - goos: windows
30 | format: zip
31 | files:
32 | - README.md
33 | - LICENSE
34 |
35 | checksum:
36 | name_template: 'checksums.txt'
37 |
38 | snapshot:
39 | name_template: "{{ incpatch .Version }}-next"
40 |
41 | changelog:
42 | sort: asc
43 | filters:
44 | exclude:
45 | - '^docs:'
46 | - '^test:'
47 | - '^ci:'
48 | - Merge pull request
49 | - Merge branch
50 |
51 | # Homebrew
52 | brews:
53 | - tap:
54 | owner: mutker
55 | name: homebrew-tap
56 | token: "{{ .Env.GITHUB_TOKEN }}"
57 | folder: Formula
58 | homepage: https://codeberg.org/mutker/mcp-todo-server
59 | description: MCP server for managing TODO.md and CHANGELOG.md files
60 | license: MIT
61 | test: |
62 | system "#{bin}/mcp-todo-server --version"
63 | install: |
64 | bin.install "mcp-todo-server"
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # mcp-todo-server
2 |
3 | Model Context Protocol (MCP) server for managing TODO.md and CHANGELOG.md files.
4 |
5 | ## Features
6 |
7 | - Precise, line-based editing and reading of file contents.
8 | - Efficient partial file access using line ranges, for efficient LLM tool usage.
9 | - Retrieve specific file content by specifying line ranges.
10 | - Fetch multiple line ranges from multiple files in a single request.
11 | - Apply line-based patches, correctly adjusting for line number changes.
12 | - Supports a wide range of character encodings (utf-8, shift_jis, latin1, etc.).
13 | - Perform atomic operations across multiple files.
14 | - Robust error handling using custom error types.
15 | - Adheres to Semantic Versioning and Keep a Changelog conventions.
16 |
17 | ## Requirements
18 |
19 | - Go v1.23+
20 | - Linux, macOS, or Windows
21 | - File system permissions for read/write operations
22 |
23 | ## Installation
24 |
25 | ```bash
26 | go install codeberg.org/mutker/mcp-todo-server/cmd/mcp-todo-server@latest
27 | ```
28 |
29 | ## Usage examples:
30 |
31 | - Ask "What are my current tasks for version 0.2.0?"
32 | - Say "Add a new task to implement OAuth authentication for version 0.2.0"
33 | - Request "Generate a changelog entry for version 0.1.0 based on completed tasks"
34 | - Say "Import my existing TODO.md file from /path/to/my/TODO.md"
35 |
36 | The server intelligently handles task parsing, version management, and provides rich semantic understanding of tasks and changelog entries.
37 |
38 | ## Available MCP Tools
39 |
40 | ### TODO.md Operations
41 |
42 | - `get-todo-tasks` - Get all tasks from TODO.md
43 | - `get-todo-tasks-by-version` - Get tasks for a specific version
44 | - `add-todo-task` - Add a new task for a specific version
45 | - `update-todo-task` - Update an existing task
46 | - `add-todo-version` - Add a new version section
47 | - `import-todo` - Import and format an existing TODO.md
48 |
49 | ### CHANGELOG.md Operations
50 |
51 | - `get-changelog` - Get all changelog entries
52 | - `get-changelog-by-version` - Get changelog entries for a specific version
53 | - `add-changelog-entry` - Add a new changelog version entry
54 | - `update-changelog-entry` - Update an existing changelog entry
55 | - `import-changelog` - Import and format an existing CHANGELOG.md
56 | - `generate-changelog-from-todo` - Generate a new CHANGELOG.md entry based on completed tasks in TODO.md
57 |
58 | ## Thanks
59 |
60 | - [tumf/mcp-text-editor](https://github.com/tumf/mcp-text-editor) for the inspiration.
61 |
62 | ## License
63 |
64 | This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full license text.
65 |
```
--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/mark3labs/mcp-go/mcp"
6 | )
7 |
8 | // Helper function to create a tool result with JSON content
9 | func newToolResultJSON(v any) (*mcp.CallToolResult, error) {
10 | jsonBytes, err := json.Marshal(v)
11 | if err != nil {
12 | return nil, err
13 | }
14 | return mcp.NewToolResultText(string(jsonBytes)), nil
15 | }
16 |
17 | // convertParams is a utility function that converts parameters from JSON
18 | // into a structured type. This is useful for processing tool parameters.
19 | func convertParams(params interface{}, dest interface{}) error {
20 | if params == nil {
21 | return nil
22 | }
23 |
24 | // Convert to JSON and unmarshal into the destination struct
25 | paramsJSON, err := json.Marshal(params)
26 | if err != nil {
27 | return err
28 | }
29 |
30 | return json.Unmarshal(paramsJSON, dest)
31 | }
32 |
```
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
```go
1 | package config
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | // Config holds the application configuration
8 | type Config struct {
9 | Files FilesConfig
10 | }
11 |
12 | // FilesConfig holds configuration related to file operations
13 | type FilesConfig struct {
14 | DefaultEncoding string
15 | AutoDetection bool
16 | }
17 |
18 | // Load loads configuration from environment variables
19 | func Load() (*Config, error) {
20 | // Set defaults
21 | cfg := &Config{
22 | Files: FilesConfig{
23 | DefaultEncoding: "utf-8",
24 | AutoDetection: true,
25 | },
26 | }
27 |
28 | // Override with environment variables
29 | if encoding := os.Getenv("MCP_DEFAULT_ENCODING"); encoding != "" {
30 | cfg.Files.DefaultEncoding = encoding
31 | }
32 |
33 | if autoDetect := os.Getenv("MCP_AUTO_DETECT_ENCODING"); autoDetect != "" {
34 | cfg.Files.AutoDetection = autoDetect == "1" || autoDetect == "true" || autoDetect == "yes"
35 | }
36 |
37 | return cfg, nil
38 | }
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | ## [0.3.0] - 2025-03-05
4 |
5 | ### Added
6 |
7 | - Support for mcp-go library integration
8 | - Complete refactoring to use mcp-go for protocol handling
9 | - Improved type safety with mcp-go parameter handling
10 | - Enhanced character encoding support
11 | - Automatic encoding detection for files
12 | - Support for additional character encodings (Chinese, Korean, and more)
13 | - Format conversion utilities for seamless encoding management
14 |
15 | ### Changed
16 |
17 | - Simplified server implementation by leveraging mcp-go functionality
18 | - Enhanced tool parameter definitions with proper schema support
19 | - Improved error handling with consistent error responses
20 | - Updated tool implementations to maintain backward compatibility
21 |
22 | ### Removed
23 |
24 | - Custom MCP protocol implementation in favor of the standard mcp-go library
25 |
26 | ## [0.2.0] - 2025-03-04
27 |
28 | ### Added
29 |
30 | - Model Context Protocol (MCP) server implementation
31 | - JSON-RPC communication layer
32 | - MCP protocol initialization and capability negotiation
33 | - Tool registration and execution system
34 | - Stdin/stdout transport support
35 | - MCP tools for TODO.md
36 | - Get all tasks
37 | - Get tasks for a specific version
38 | - Add a new task
39 | - Update an existing task
40 | - Add a new version section
41 | - Import and format an existing TODO.md
42 | - MCP tools for CHANGELOG.md
43 | - Get all changelog items
44 | - Get changelog items for a specific version
45 | - Add a new changelog version entry
46 | - Update existing changelog entries
47 | - Import and format an existing CHANGELOG.md
48 | - Generate a new CHANGELOG.md based on completed tasks in TODO.md
49 |
50 | ### Changed
51 |
52 | - Updated README with MCP server documentation
53 | - Improved error handling and validation for tools
54 |
55 | ## [0.1.0] - 2025-03-03
56 |
57 | ### Added
58 |
59 | - Initial implementation of MCP Todo Server
60 | - Server framework with error handling and middleware
61 | - File operations with line-based reading and editing
62 | - Hash-based validation for concurrent editing
63 | - TODO.md management operations
64 | - Parse TODO format
65 | - Read and update tasks
66 | - Add tasks and versions
67 | - CHANGELOG.md management operations
68 | - Parse CHANGELOG format
69 | - Read and update entries
70 | - Add new entries
71 | - Multi-file atomic operations
72 | - Comprehensive encoding support (utf-8, shift_jis, latin1, etc.)
73 | - Goreleaser configuration for multi-platform builds
74 |
```
--------------------------------------------------------------------------------
/cmd/mcp-todo-server/main.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "log/slog"
8 | "os"
9 | "os/signal"
10 | "syscall"
11 | "time"
12 |
13 | "codeberg.org/mutker/mcp-todo-server/internal/config"
14 | "codeberg.org/mutker/mcp-todo-server/internal/mcp"
15 | "github.com/mark3labs/mcp-go/server"
16 | )
17 |
18 | var (
19 | version = "0.3.0"
20 | verboseFlag = flag.Bool("verbose", false, "Enable verbose logging")
21 | versionFlag = flag.Bool("version", false, "Show version information")
22 | )
23 |
24 | func main() {
25 | // Define command-line flags
26 | flag.Parse()
27 |
28 | // Show version and exit if requested
29 | if *versionFlag {
30 | fmt.Printf("mcp-todo-server version %s\n", version)
31 | os.Exit(0)
32 | }
33 |
34 | // Initialize logger to write to stderr instead of stdout
35 | // This is necessary because stdout is reserved for MCP JSON-RPC communication
36 | logLevel := slog.LevelInfo
37 | if *verboseFlag {
38 | logLevel = slog.LevelDebug
39 | }
40 |
41 | // Create a custom handler that formats logs in a more human-readable format
42 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
43 | Level: logLevel,
44 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
45 | // Format timestamp to be more human readable
46 | if a.Key == "time" {
47 | if t, ok := a.Value.Any().(time.Time); ok {
48 | return slog.Attr{
49 | Key: "time",
50 | Value: slog.StringValue(t.Format("2006-01-02 15:04:05")),
51 | }
52 | }
53 | }
54 | return a
55 | },
56 | })
57 |
58 | logger := slog.New(handler)
59 | slog.SetDefault(logger)
60 |
61 | // Load configuration
62 | cfg, err := config.Load()
63 | if err != nil {
64 | logger.Error("Failed to load configuration", "error", err)
65 | os.Exit(1)
66 | }
67 |
68 | // Print version information
69 | logger.Info(fmt.Sprintf("Starting MCP Todo Server v%s", version))
70 |
71 | // Create context for graceful shutdown
72 | ctx, cancel := context.WithCancel(context.Background())
73 | defer cancel()
74 |
75 | // Create a channel to listen for OS signals
76 | quit := make(chan os.Signal, 1)
77 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
78 |
79 | // Create and start the MCP server
80 | mcpServer := server.NewMCPServer(
81 | "mcp-todo-server",
82 | version,
83 | server.WithResourceCapabilities(false, false), // Disable resources for now
84 | server.WithLogging(),
85 | )
86 |
87 | // Register tools
88 | mcp.RegisterTools(ctx, mcpServer, cfg)
89 |
90 | // Start the MCP server in a goroutine
91 | go func() {
92 | logger.Info("MCP server ready and listening on stdin/stdout")
93 | if err := server.ServeStdio(mcpServer); err != nil {
94 | logger.Error(fmt.Sprintf("MCP server error: %v", err))
95 | os.Exit(1)
96 | }
97 | }()
98 |
99 | // Wait for signal
100 | <-quit
101 | logger.Info("Received shutdown signal, closing server...")
102 |
103 | // Cancel the context to initiate shutdown
104 | cancel()
105 |
106 | // Give it a moment to clean up
107 | // time.Sleep(500 * time.Millisecond) // No need with mcp-go
108 |
109 | logger.Info("MCP Todo Server has shut down gracefully")
110 | }
111 |
```
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
```markdown
1 | # TODO
2 |
3 | ## v0.1.0
4 |
5 | - [x] Set up project structure
6 | - [x] Create Go module
7 | - [x] Set up directory structure (cmd, internal, pkg)
8 | - [x] Add gitignore file
9 | - [x] Implement file operations
10 | - [x] Design file service interfaces
11 | - [x] Implement line-based file reading with range support
12 | - [x] Implement line-based editing operations
13 | - [x] Add hash-based validation for concurrent editing
14 | - [x] Implement TODO.md operations
15 | - [x] Parse TODO.md format
16 | - [x] Implement operations for reading TODO items
17 | - [x] Implement operations for updating task status
18 | - [x] Implement operations for adding new tasks and versions
19 | - [x] Implement CHANGELOG.md operations
20 | - [x] Parse CHANGELOG.md format
21 | - [x] Implement operations for reading changelog entries
22 | - [x] Implement operations for adding new entries
23 | - [x] Implement operations for updating existing entries
24 | - [x] Add multi-file atomic operations
25 | - [x] Implement transaction-like behavior for multi-file edits
26 | - [x] Add rollback capability for failed operations
27 | - [x] Add encoding support
28 | - [x] Implement detection and handling of various encodings
29 | - [x] Add support for utf-8, shift_jis, latin1
30 | - [x] Set up goreleaser configuration
31 | - [x] Create .goreleaser.yml
32 | - [x] Configure build settings for different platforms
33 | - [x] Set up release workflow
34 |
35 | ## v0.2.0
36 |
37 | - [x] Implement MCP server
38 | - [x] Create JSON-RPC communication layer
39 | - [x] Implement MCP protocol initialization
40 | - [x] Add tool registration and execution
41 | - [x] Support stdin/stdout transport
42 | - [x] Implement MCP tools for TODO.md
43 | - [x] Get all tasks
44 | - [x] Get tasks for a specific version
45 | - [x] Add a new task
46 | - [x] Add a new task for a specific version
47 | - [x] Update an existing task
48 | - [x] Add a new version section
49 | - [x] Import and format an existing TODO.md
50 | - [x] Implement MCP tools for CHANGELOG.md
51 | - [x] Get all changelog items
52 | - [x] Get changelog items for a specific version
53 | - [x] Add a new changelog version entry
54 | - [x] Add a new changelog entry for a specific version
55 | - [x] Update a existing changelog entry
56 | - [x] Import and format an existing CHANGELOG.md
57 | - [x] Generate a new CHANGELOG.md based on completed tasks in TODO.md
58 |
59 | ## v0.3.0
60 |
61 | - [x] Refactor to use `mcp-go` library
62 | - [x] Replace custom MCP implementation with `mcp-go`
63 | - [x] Adapt tool handlers to `mcp-go` API
64 | - [x] Ensure all TODO.md operations are supported
65 | - [x] Ensure all CHANGELOG.md operations are supported
66 | - [x] Clean up legacy MCP server code
67 | - [x] Remove obsolete code replaced by mcp-go
68 | - [x] Refactor server initialization
69 | - [x] Update error handling for MCP protocol
70 | - [x] Support for additional character encodings and format detection
71 | - [x] Add automatic encoding detection
72 | - [x] Support for more exotic character encodings
73 | - [x] Format conversion utilities
74 |
```
--------------------------------------------------------------------------------
/internal/services/changelog/changelog.go:
--------------------------------------------------------------------------------
```go
1 | package changelog
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "regexp"
7 | "strings"
8 |
9 | "codeberg.org/mutker/mcp-todo-server/internal/config"
10 | "codeberg.org/mutker/mcp-todo-server/internal/services/file"
11 | )
12 |
13 | const (
14 | // Default CHANGELOG file name
15 | changelogFileName = "CHANGELOG.md"
16 | )
17 |
18 | var (
19 | // ErrVersionNotFound is returned when a version is not found
20 | ErrVersionNotFound = errors.New("version not found")
21 |
22 | // ErrVersionExists is returned when a version already exists
23 | ErrVersionExists = errors.New("version already exists")
24 | )
25 |
26 | // versionRegex matches a version header (e.g., "## [1.0.0] - 2023-01-01")
27 | var versionRegex = regexp.MustCompile(`^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$`)
28 |
29 | // sectionRegex matches a section header (e.g., "### Added" or "### Fixed")
30 | var sectionRegex = regexp.MustCompile(`^### ([A-Za-z]+)$`)
31 |
32 | // listItemRegex matches a list item (e.g., "- List item")
33 | var listItemRegex = regexp.MustCompile(`^(\s*)- (.+)$`)
34 |
35 | // ChangelogContent represents the content of a changelog entry
36 | type ChangelogContent struct {
37 | Added []string `json:"added,omitempty"`
38 | Changed []string `json:"changed,omitempty"`
39 | Deprecated []string `json:"deprecated,omitempty"`
40 | Removed []string `json:"removed,omitempty"`
41 | Fixed []string `json:"fixed,omitempty"`
42 | Security []string `json:"security,omitempty"`
43 | }
44 |
45 | // VersionEntry represents a version entry in the changelog
46 | type VersionEntry struct {
47 | Version string `json:"version"`
48 | Date string `json:"date"`
49 | Content *ChangelogContent `json:"content"`
50 | }
51 |
52 | // Changelog represents the entire CHANGELOG.md file
53 | type Changelog struct {
54 | Versions []*VersionEntry `json:"versions"`
55 | }
56 |
57 | // Service provides changelog operations
58 | type Service struct {
59 | config *config.Config
60 | fileService *file.Service
61 | }
62 |
63 | // NewService creates a new changelog service
64 | func NewService(cfg *config.Config) *Service {
65 | fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
66 |
67 | return &Service{
68 | config: cfg,
69 | fileService: fileService,
70 | }
71 | }
72 |
73 | // GetAll retrieves the entire changelog
74 | func (s *Service) GetAll() (*Changelog, error) {
75 | lines, _, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
76 | if err != nil {
77 | if errors.Is(err, file.ErrFileNotFound) {
78 | // Return empty changelog if file doesn't exist
79 | return &Changelog{Versions: []*VersionEntry{}}, nil
80 | }
81 | return nil, fmt.Errorf("failed to read CHANGELOG file: %w", err)
82 | }
83 |
84 | return s.parseChangelogFile(lines)
85 | }
86 |
87 | // GetByVersion retrieves a specific version entry
88 | func (s *Service) GetByVersion(version string) (*VersionEntry, error) {
89 | changelog, err := s.GetAll()
90 | if err != nil {
91 | return nil, err
92 | }
93 |
94 | // Find the requested version
95 | for _, v := range changelog.Versions {
96 | if v.Version == version {
97 | return v, nil
98 | }
99 | }
100 |
101 | return nil, nil
102 | }
103 |
104 | // AddEntry adds a new changelog entry
105 | func (s *Service) AddEntry(version string, date string, content *ChangelogContent) error {
106 | lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
107 | if err != nil && !errors.Is(err, file.ErrFileNotFound) {
108 | return fmt.Errorf("failed to read CHANGELOG file: %w", err)
109 | }
110 |
111 | // If file doesn't exist, create it
112 | if errors.Is(err, file.ErrFileNotFound) {
113 | newLines := []string{
114 | "# Changelog",
115 | "",
116 | }
117 | err = s.fileService.InsertLines(changelogFileName, "", s.config.Files.DefaultEncoding, 1, newLines)
118 | if err != nil {
119 | return fmt.Errorf("failed to create CHANGELOG file: %w", err)
120 | }
121 |
122 | // Re-read the file
123 | lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
124 | if err != nil {
125 | return fmt.Errorf("failed to read CHANGELOG file: %w", err)
126 | }
127 | }
128 |
129 | // Parse the file to check if version already exists
130 | changelog, err := s.parseChangelogFile(lines)
131 | if err != nil {
132 | return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
133 | }
134 |
135 | // Check if version already exists
136 | for _, v := range changelog.Versions {
137 | if v.Version == version {
138 | return ErrVersionExists
139 | }
140 | }
141 |
142 | // Generate the entry
143 | entryLines := []string{}
144 |
145 | // Add version header
146 | entryLines = append(entryLines, fmt.Sprintf("## [%s] - %s", version, date))
147 | entryLines = append(entryLines, "")
148 |
149 | // Add sections
150 | if len(content.Added) > 0 {
151 | entryLines = append(entryLines, "### Added")
152 | entryLines = append(entryLines, "")
153 | for _, item := range content.Added {
154 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
155 | }
156 | entryLines = append(entryLines, "")
157 | }
158 |
159 | if len(content.Changed) > 0 {
160 | entryLines = append(entryLines, "### Changed")
161 | entryLines = append(entryLines, "")
162 | for _, item := range content.Changed {
163 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
164 | }
165 | entryLines = append(entryLines, "")
166 | }
167 |
168 | if len(content.Deprecated) > 0 {
169 | entryLines = append(entryLines, "### Deprecated")
170 | entryLines = append(entryLines, "")
171 | for _, item := range content.Deprecated {
172 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
173 | }
174 | entryLines = append(entryLines, "")
175 | }
176 |
177 | if len(content.Removed) > 0 {
178 | entryLines = append(entryLines, "### Removed")
179 | entryLines = append(entryLines, "")
180 | for _, item := range content.Removed {
181 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
182 | }
183 | entryLines = append(entryLines, "")
184 | }
185 |
186 | if len(content.Fixed) > 0 {
187 | entryLines = append(entryLines, "### Fixed")
188 | entryLines = append(entryLines, "")
189 | for _, item := range content.Fixed {
190 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
191 | }
192 | entryLines = append(entryLines, "")
193 | }
194 |
195 | if len(content.Security) > 0 {
196 | entryLines = append(entryLines, "### Security")
197 | entryLines = append(entryLines, "")
198 | for _, item := range content.Security {
199 | entryLines = append(entryLines, fmt.Sprintf("- %s", item))
200 | }
201 | entryLines = append(entryLines, "")
202 | }
203 |
204 | // Find position to insert the new entry
205 | // We'll add it after the title and before any other version
206 | position := 2 // After "# Changelog" and empty line
207 | for i, line := range lines {
208 | if versionRegex.MatchString(line) {
209 | position = i
210 | break
211 | }
212 | }
213 |
214 | // Insert the entry
215 | err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, position, entryLines)
216 | if err != nil {
217 | return fmt.Errorf("failed to add entry: %w", err)
218 | }
219 |
220 | return nil
221 | }
222 |
223 | // UpdateEntry updates an existing changelog entry
224 | func (s *Service) UpdateEntry(version string, date string, content *ChangelogContent) error {
225 | lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
226 | if err != nil {
227 | return fmt.Errorf("failed to read CHANGELOG file: %w", err)
228 | }
229 |
230 | // Parse the file to locate the version
231 | changelog, err := s.parseChangelogFile(lines)
232 | if err != nil {
233 | return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
234 | }
235 |
236 | // Find the version entry
237 | var targetVersion *VersionEntry
238 | for _, v := range changelog.Versions {
239 | if v.Version == version {
240 | targetVersion = v
241 | break
242 | }
243 | }
244 |
245 | if targetVersion == nil {
246 | return ErrVersionNotFound
247 | }
248 |
249 | // Find the start and end line numbers for this version
250 | startLine := 0
251 | endLine := len(lines)
252 |
253 | for i, line := range lines {
254 | if versionRegex.MatchString(line) {
255 | versionMatch := versionRegex.FindStringSubmatch(line)
256 | if versionMatch[1] == version {
257 | startLine = i
258 |
259 | // Find the end line (next version or end of file)
260 | for j := i + 1; j < len(lines); j++ {
261 | if versionRegex.MatchString(lines[j]) {
262 | endLine = j
263 | break
264 | }
265 | }
266 | break
267 | }
268 | }
269 | }
270 |
271 | // Generate new version entry
272 | newLines := []string{}
273 |
274 | // Update version header if date is provided
275 | if date != "" {
276 | newLines = append(newLines, fmt.Sprintf("## [%s] - %s", version, date))
277 | } else {
278 | newLines = append(newLines, lines[startLine]) // Keep original date
279 | }
280 | newLines = append(newLines, "")
281 |
282 | // Add sections
283 | // We'll generate completely new content rather than trying to modify existing content
284 | if content.Added != nil && len(content.Added) > 0 {
285 | newLines = append(newLines, "### Added")
286 | newLines = append(newLines, "")
287 | for _, item := range content.Added {
288 | newLines = append(newLines, fmt.Sprintf("- %s", item))
289 | }
290 | newLines = append(newLines, "")
291 | }
292 |
293 | if content.Changed != nil && len(content.Changed) > 0 {
294 | newLines = append(newLines, "### Changed")
295 | newLines = append(newLines, "")
296 | for _, item := range content.Changed {
297 | newLines = append(newLines, fmt.Sprintf("- %s", item))
298 | }
299 | newLines = append(newLines, "")
300 | }
301 |
302 | if content.Deprecated != nil && len(content.Deprecated) > 0 {
303 | newLines = append(newLines, "### Deprecated")
304 | newLines = append(newLines, "")
305 | for _, item := range content.Deprecated {
306 | newLines = append(newLines, fmt.Sprintf("- %s", item))
307 | }
308 | newLines = append(newLines, "")
309 | }
310 |
311 | if content.Removed != nil && len(content.Removed) > 0 {
312 | newLines = append(newLines, "### Removed")
313 | newLines = append(newLines, "")
314 | for _, item := range content.Removed {
315 | newLines = append(newLines, fmt.Sprintf("- %s", item))
316 | }
317 | newLines = append(newLines, "")
318 | }
319 |
320 | if content.Fixed != nil && len(content.Fixed) > 0 {
321 | newLines = append(newLines, "### Fixed")
322 | newLines = append(newLines, "")
323 | for _, item := range content.Fixed {
324 | newLines = append(newLines, fmt.Sprintf("- %s", item))
325 | }
326 | newLines = append(newLines, "")
327 | }
328 |
329 | if content.Security != nil && len(content.Security) > 0 {
330 | newLines = append(newLines, "### Security")
331 | newLines = append(newLines, "")
332 | for _, item := range content.Security {
333 | newLines = append(newLines, fmt.Sprintf("- %s", item))
334 | }
335 | newLines = append(newLines, "")
336 | }
337 |
338 | // Delete the old version entry
339 | var lineNumbers []int
340 | for i := startLine; i < endLine; i++ {
341 | lineNumbers = append(lineNumbers, i+1)
342 | }
343 |
344 | err = s.fileService.DeleteLines(changelogFileName, hash, s.config.Files.DefaultEncoding, lineNumbers)
345 | if err != nil {
346 | return fmt.Errorf("failed to delete old entry: %w", err)
347 | }
348 |
349 | // Re-read the file to get the updated hash
350 | lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
351 | if err != nil {
352 | return fmt.Errorf("failed to read CHANGELOG file: %w", err)
353 | }
354 |
355 | // Insert the new version entry
356 | err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, startLine+1, newLines)
357 | if err != nil {
358 | return fmt.Errorf("failed to insert new entry: %w", err)
359 | }
360 |
361 | return nil
362 | }
363 |
364 | // parseChangelogFile parses a CHANGELOG.md file into a structured representation
365 | func (s *Service) parseChangelogFile(lines []string) (*Changelog, error) {
366 | changelog := &Changelog{
367 | Versions: []*VersionEntry{},
368 | }
369 |
370 | var currentVersion *VersionEntry
371 | var currentSection string
372 |
373 | for _, line := range lines {
374 | // Skip empty lines and title
375 | if line == "" || line == "# Changelog" {
376 | continue
377 | }
378 |
379 | // Check if it's a version header
380 | if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
381 | version := versionMatch[1]
382 | date := versionMatch[2]
383 |
384 | currentVersion = &VersionEntry{
385 | Version: version,
386 | Date: date,
387 | Content: &ChangelogContent{},
388 | }
389 | changelog.Versions = append(changelog.Versions, currentVersion)
390 | currentSection = ""
391 | continue
392 | }
393 |
394 | // If no current version, skip
395 | if currentVersion == nil {
396 | continue
397 | }
398 |
399 | // Check if it's a section header
400 | if sectionMatch := sectionRegex.FindStringSubmatch(line); sectionMatch != nil {
401 | currentSection = strings.ToLower(sectionMatch[1])
402 | continue
403 | }
404 |
405 | // Check if it's a list item
406 | if listItemMatch := listItemRegex.FindStringSubmatch(line); listItemMatch != nil {
407 | item := listItemMatch[2]
408 |
409 | // Add to the appropriate section
410 | switch currentSection {
411 | case "added":
412 | currentVersion.Content.Added = append(currentVersion.Content.Added, item)
413 | case "changed":
414 | currentVersion.Content.Changed = append(currentVersion.Content.Changed, item)
415 | case "deprecated":
416 | currentVersion.Content.Deprecated = append(currentVersion.Content.Deprecated, item)
417 | case "removed":
418 | currentVersion.Content.Removed = append(currentVersion.Content.Removed, item)
419 | case "fixed":
420 | currentVersion.Content.Fixed = append(currentVersion.Content.Fixed, item)
421 | case "security":
422 | currentVersion.Content.Security = append(currentVersion.Content.Security, item)
423 | }
424 | }
425 | }
426 |
427 | return changelog, nil
428 | }
```
--------------------------------------------------------------------------------
/internal/services/todo/todo.go:
--------------------------------------------------------------------------------
```go
1 | package todo
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "regexp"
7 | "strings"
8 |
9 | "codeberg.org/mutker/mcp-todo-server/internal/config"
10 | "codeberg.org/mutker/mcp-todo-server/internal/services/file"
11 | )
12 |
13 | const (
14 | // Default TODO file name
15 | todoFileName = "TODO.md"
16 | )
17 |
18 | var (
19 | // ErrTaskNotFound is returned when a task is not found
20 | ErrTaskNotFound = errors.New("task not found")
21 |
22 | // ErrVersionNotFound is returned when a version is not found
23 | ErrVersionNotFound = errors.New("version not found")
24 |
25 | // ErrVersionExists is returned when a version already exists
26 | ErrVersionExists = errors.New("version already exists")
27 | )
28 |
29 | // versionRegex matches a version header (e.g., "## v1.0.0")
30 | var versionRegex = regexp.MustCompile(`^## v(\d+\.\d+\.\d+)$`)
31 |
32 | // taskRegex matches a task line (e.g., "- [ ] Task description" or "- [x] Completed task")
33 | var taskRegex = regexp.MustCompile(`^(\s*)- \[([ xX])\] (.+)$`)
34 |
35 | // Task represents a todo task
36 | type Task struct {
37 | ID string `json:"id"`
38 | Description string `json:"description"`
39 | Completed bool `json:"completed"`
40 | Version string `json:"version"`
41 | LineNumber int `json:"line_number"`
42 | Indent int `json:"indent"`
43 | SubTasks []*Task `json:"subtasks,omitempty"`
44 | }
45 |
46 | // VersionTasks represents tasks grouped by version
47 | type VersionTasks struct {
48 | Version string `json:"version"`
49 | Tasks []*Task `json:"tasks"`
50 | }
51 |
52 | // TodoList represents the entire TODO.md file
53 | type TodoList struct {
54 | Versions []*VersionTasks `json:"versions"`
55 | }
56 |
57 | // Service provides todo operations
58 | type Service struct {
59 | config *config.Config
60 | fileService *file.Service
61 | }
62 |
63 | // NewService creates a new todo service
64 | func NewService(cfg *config.Config) *Service {
65 | fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
66 |
67 | return &Service{
68 | config: cfg,
69 | fileService: fileService,
70 | }
71 | }
72 |
73 | // GetAll retrieves all tasks from TODO.md
74 | func (s *Service) GetAll() (*TodoList, error) {
75 | lines, _, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
76 | if err != nil {
77 | if errors.Is(err, file.ErrFileNotFound) {
78 | // Return empty todo list if file doesn't exist
79 | return &TodoList{Versions: []*VersionTasks{}}, nil
80 | }
81 | return nil, fmt.Errorf("failed to read TODO file: %w", err)
82 | }
83 |
84 | return s.parseTodoFile(lines)
85 | }
86 |
87 | // GetByVersion retrieves tasks for a specific version
88 | func (s *Service) GetByVersion(version string) (*VersionTasks, error) {
89 | todoList, err := s.GetAll()
90 | if err != nil {
91 | return nil, err
92 | }
93 |
94 | // Find the requested version
95 | for _, v := range todoList.Versions {
96 | if v.Version == version {
97 | return v, nil
98 | }
99 | }
100 |
101 | return nil, nil
102 | }
103 |
104 | // AddTask adds a new task to TODO.md
105 | func (s *Service) AddTask(version string, description string, parentID string) (string, error) {
106 | lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
107 | if err != nil && !errors.Is(err, file.ErrFileNotFound) {
108 | return "", fmt.Errorf("failed to read TODO file: %w", err)
109 | }
110 |
111 | // If file doesn't exist, create it with the version
112 | if errors.Is(err, file.ErrFileNotFound) {
113 | err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
114 | "# TODO",
115 | "",
116 | fmt.Sprintf("## v%s", version),
117 | })
118 | if err != nil {
119 | return "", fmt.Errorf("failed to create TODO file: %w", err)
120 | }
121 |
122 | // Re-read the file
123 | lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
124 | if err != nil {
125 | return "", fmt.Errorf("failed to read TODO file: %w", err)
126 | }
127 | }
128 |
129 | // Parse the file to locate the version and determine insert position
130 | todoList, err := s.parseTodoFile(lines)
131 | if err != nil {
132 | return "", fmt.Errorf("failed to parse TODO file: %w", err)
133 | }
134 |
135 | // Check if version exists
136 | versionExists := false
137 | var versionTasksObj *VersionTasks
138 |
139 | for _, v := range todoList.Versions {
140 | if v.Version == version {
141 | versionExists = true
142 | versionTasksObj = v
143 | break
144 | }
145 | }
146 |
147 | // If version doesn't exist, add it
148 | if !versionExists {
149 | // Find the position to insert the new version
150 | // We'll add it after the last version or at the end if no versions exist
151 | position := len(lines) + 1
152 | for i := len(lines) - 1; i >= 0; i-- {
153 | if versionRegex.MatchString(lines[i]) {
154 | position = i + 1
155 | break
156 | }
157 | }
158 |
159 | // Insert the version header
160 | err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
161 | "",
162 | fmt.Sprintf("## v%s", version),
163 | })
164 | if err != nil {
165 | return "", fmt.Errorf("failed to add version: %w", err)
166 | }
167 |
168 | // Re-read the file
169 | lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
170 | if err != nil {
171 | return "", fmt.Errorf("failed to read TODO file: %w", err)
172 | }
173 |
174 | // Re-parse the file
175 | todoList, err = s.parseTodoFile(lines)
176 | if err != nil {
177 | return "", fmt.Errorf("failed to parse TODO file: %w", err)
178 | }
179 |
180 | // Find the version again
181 | for _, v := range todoList.Versions {
182 | if v.Version == version {
183 | versionTasksObj = v
184 | break
185 | }
186 | }
187 | }
188 |
189 | // Determine insertion position and indentation
190 | var insertPosition int
191 | var indentation string
192 |
193 | if parentID == "" {
194 | // If no parent task, add at the end of the version section
195 | if len(versionTasksObj.Tasks) == 0 {
196 | // If no tasks in this version, add right after the version header
197 | for i, line := range lines {
198 | if line == fmt.Sprintf("## v%s", version) {
199 | insertPosition = i + 1
200 | break
201 | }
202 | }
203 | } else {
204 | // Find the last task in this version
205 | lastTask := versionTasksObj.Tasks[len(versionTasksObj.Tasks)-1]
206 |
207 | // Handle case where last task has subtasks
208 | // We need to find the last subtask recursively
209 | var findLastLine func(*Task) int
210 | findLastLine = func(t *Task) int {
211 | if len(t.SubTasks) == 0 {
212 | return t.LineNumber
213 | }
214 | return findLastLine(t.SubTasks[len(t.SubTasks)-1])
215 | }
216 |
217 | lastLine := findLastLine(lastTask)
218 | insertPosition = lastLine
219 | }
220 | } else {
221 | // Find the parent task
222 | var parentTask *Task
223 | var findTask func([]*Task) *Task
224 | findTask = func(tasks []*Task) *Task {
225 | for _, t := range tasks {
226 | if t.ID == parentID {
227 | return t
228 | }
229 | if result := findTask(t.SubTasks); result != nil {
230 | return result
231 | }
232 | }
233 | return nil
234 | }
235 |
236 | for _, v := range todoList.Versions {
237 | if parent := findTask(v.Tasks); parent != nil {
238 | parentTask = parent
239 | break
240 | }
241 | }
242 |
243 | if parentTask == nil {
244 | return "", ErrTaskNotFound
245 | }
246 |
247 | // Add as a subtask of the parent
248 | insertPosition = parentTask.LineNumber
249 | indentation = strings.Repeat(" ", parentTask.Indent+2)
250 | }
251 |
252 | // Add the task
253 | newTaskLine := fmt.Sprintf("%s- [ ] %s", indentation, description)
254 | err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, insertPosition+1, []string{newTaskLine})
255 | if err != nil {
256 | return "", fmt.Errorf("failed to add task: %w", err)
257 | }
258 |
259 | // Re-read and parse to get the ID of the new task
260 | lines, _, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
261 | if err != nil {
262 | return "", fmt.Errorf("failed to read TODO file: %w", err)
263 | }
264 |
265 | todoList, err = s.parseTodoFile(lines)
266 | if err != nil {
267 | return "", fmt.Errorf("failed to parse TODO file: %w", err)
268 | }
269 |
270 | // Find the task we just added to get its ID
271 | var newTaskID string
272 | for _, v := range todoList.Versions {
273 | if v.Version == version {
274 | for _, t := range v.Tasks {
275 | if t.LineNumber == insertPosition+1 && t.Description == description {
276 | newTaskID = t.ID
277 | return newTaskID, nil
278 | }
279 |
280 | // Also check subtasks
281 | var findNewTask func([]*Task) string
282 | findNewTask = func(tasks []*Task) string {
283 | for _, st := range tasks {
284 | if st.LineNumber == insertPosition+1 && st.Description == description {
285 | return st.ID
286 | }
287 | if id := findNewTask(st.SubTasks); id != "" {
288 | return id
289 | }
290 | }
291 | return ""
292 | }
293 |
294 | if id := findNewTask(t.SubTasks); id != "" {
295 | return id, nil
296 | }
297 | }
298 | }
299 | }
300 |
301 | // If we couldn't find the new task (shouldn't happen), generate a best-guess ID
302 | return generateTaskID(version, description), nil
303 | }
304 |
305 | // UpdateTask updates an existing task
306 | func (s *Service) UpdateTask(taskID string, completed *bool, description *string) error {
307 | lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
308 | if err != nil {
309 | return fmt.Errorf("failed to read TODO file: %w", err)
310 | }
311 |
312 | todoList, err := s.parseTodoFile(lines)
313 | if err != nil {
314 | return fmt.Errorf("failed to parse TODO file: %w", err)
315 | }
316 |
317 | // Find the task
318 | var taskToUpdate *Task
319 | var findTask func([]*Task) *Task
320 | findTask = func(tasks []*Task) *Task {
321 | for _, t := range tasks {
322 | if t.ID == taskID {
323 | return t
324 | }
325 | if result := findTask(t.SubTasks); result != nil {
326 | return result
327 | }
328 | }
329 | return nil
330 | }
331 |
332 | for _, v := range todoList.Versions {
333 | if task := findTask(v.Tasks); task != nil {
334 | taskToUpdate = task
335 | break
336 | }
337 | }
338 |
339 | if taskToUpdate == nil {
340 | return ErrTaskNotFound
341 | }
342 |
343 | // Parse the line to extract indentation and create the new line
344 | indentMatch := strings.Repeat(" ", taskToUpdate.Indent)
345 |
346 | // Determine completion status
347 | completionStatus := " "
348 | if completed != nil {
349 | if *completed {
350 | completionStatus = "x"
351 | }
352 | } else if taskToUpdate.Completed {
353 | completionStatus = "x"
354 | }
355 |
356 | // Determine description
357 | desc := taskToUpdate.Description
358 | if description != nil {
359 | desc = *description
360 | }
361 |
362 | // Create the new line
363 | newLine := fmt.Sprintf("%s- [%s] %s", indentMatch, completionStatus, desc)
364 |
365 | // Update the line
366 | lineEdits := map[int]string{
367 | taskToUpdate.LineNumber: newLine,
368 | }
369 |
370 | err = s.fileService.EditLines(todoFileName, hash, s.config.Files.DefaultEncoding, lineEdits)
371 | if err != nil {
372 | return fmt.Errorf("failed to update task: %w", err)
373 | }
374 |
375 | return nil
376 | }
377 |
378 | // AddVersion adds a new version section to TODO.md
379 | func (s *Service) AddVersion(version string) error {
380 | lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
381 | if err != nil && !errors.Is(err, file.ErrFileNotFound) {
382 | return fmt.Errorf("failed to read TODO file: %w", err)
383 | }
384 |
385 | // If file doesn't exist, create it
386 | if errors.Is(err, file.ErrFileNotFound) {
387 | err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
388 | "# TODO",
389 | "",
390 | fmt.Sprintf("## v%s", version),
391 | })
392 | if err != nil {
393 | return fmt.Errorf("failed to create TODO file: %w", err)
394 | }
395 | return nil
396 | }
397 |
398 | // Check if version already exists
399 | todoList, err := s.parseTodoFile(lines)
400 | if err != nil {
401 | return fmt.Errorf("failed to parse TODO file: %w", err)
402 | }
403 |
404 | for _, v := range todoList.Versions {
405 | if v.Version == version {
406 | return ErrVersionExists
407 | }
408 | }
409 |
410 | // Find the position to insert the new version
411 | // We'll add it before the first greater version or at the end if no greater version exists
412 | position := len(lines) + 1
413 |
414 | // Make sure todoList.Versions is sorted by version
415 | // For simplicity, we'll just add it at the end for now
416 | // In a real implementation, we would sort the versions semantically
417 |
418 | // Insert the version
419 | err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
420 | "",
421 | fmt.Sprintf("## v%s", version),
422 | })
423 | if err != nil {
424 | return fmt.Errorf("failed to add version: %w", err)
425 | }
426 |
427 | return nil
428 | }
429 |
430 | // parseTodoFile parses a TODO.md file into a structured representation
431 | func (s *Service) parseTodoFile(lines []string) (*TodoList, error) {
432 | todoList := &TodoList{
433 | Versions: []*VersionTasks{},
434 | }
435 |
436 | var currentVersion *VersionTasks
437 | var taskStack [][]*Task
438 |
439 | for i, line := range lines {
440 | lineNum := i + 1
441 |
442 | // Skip empty lines and the title
443 | if line == "" || line == "# TODO" {
444 | continue
445 | }
446 |
447 | // Check if it's a version header
448 | if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
449 | version := versionMatch[1]
450 | currentVersion = &VersionTasks{
451 | Version: version,
452 | Tasks: []*Task{},
453 | }
454 | todoList.Versions = append(todoList.Versions, currentVersion)
455 | taskStack = nil
456 | continue
457 | }
458 |
459 | // If no current version, skip
460 | if currentVersion == nil {
461 | continue
462 | }
463 |
464 | // Check if it's a task
465 | if taskMatch := taskRegex.FindStringSubmatch(line); taskMatch != nil {
466 | indentation := taskMatch[1]
467 | completed := taskMatch[2] == "x" || taskMatch[2] == "X"
468 | description := taskMatch[3]
469 | indent := len(indentation)
470 |
471 | task := &Task{
472 | ID: generateTaskID(currentVersion.Version, description),
473 | Description: description,
474 | Completed: completed,
475 | Version: currentVersion.Version,
476 | LineNumber: lineNum,
477 | Indent: indent,
478 | SubTasks: []*Task{},
479 | }
480 |
481 | // Handle task hierarchy based on indentation
482 | if indent == 0 {
483 | // Top-level task
484 | currentVersion.Tasks = append(currentVersion.Tasks, task)
485 | taskStack = [][]*Task{{task}}
486 | } else {
487 | // Find the parent task based on indentation
488 | level := indent / 2
489 |
490 | // Ensure the stack has enough levels
491 | for len(taskStack) <= level {
492 | taskStack = append(taskStack, []*Task{})
493 | }
494 |
495 | // Add the task to its level
496 | taskStack[level] = append(taskStack[level], task)
497 |
498 | // Add as a subtask to the parent
499 | if level > 0 {
500 | parentLevel := level - 1
501 | parentTasks := taskStack[parentLevel]
502 | if len(parentTasks) > 0 {
503 | parent := parentTasks[len(parentTasks)-1]
504 | parent.SubTasks = append(parent.SubTasks, task)
505 | }
506 | }
507 | }
508 | }
509 | }
510 |
511 | return todoList, nil
512 | }
513 |
514 | // generateTaskID generates a unique ID for a task
515 | func generateTaskID(version string, description string) string {
516 | // Generate a simple ID based on the version and description
517 | // In a real implementation, you might want to use something more robust
518 | cleanDesc := strings.ToLower(description)
519 | cleanDesc = strings.Replace(cleanDesc, " ", "-", -1)
520 | cleanDesc = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(cleanDesc, "")
521 |
522 | if len(cleanDesc) > 20 {
523 | cleanDesc = cleanDesc[:20]
524 | }
525 |
526 | return fmt.Sprintf("%s-%s", version, cleanDesc)
527 | }
```
--------------------------------------------------------------------------------
/internal/services/file/file.go:
--------------------------------------------------------------------------------
```go
1 | package file
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/hex"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "os"
10 | "path/filepath"
11 | "strings"
12 |
13 | "golang.org/x/net/html/charset"
14 | "golang.org/x/text/encoding"
15 | "golang.org/x/text/encoding/charmap"
16 | "golang.org/x/text/encoding/japanese"
17 | "golang.org/x/text/encoding/korean"
18 | "golang.org/x/text/encoding/simplifiedchinese"
19 | "golang.org/x/text/encoding/traditionalchinese"
20 | "golang.org/x/text/encoding/unicode"
21 | )
22 |
23 | var (
24 | // ErrFileNotFound is returned when a file does not exist
25 | ErrFileNotFound = errors.New("file not found")
26 |
27 | // ErrContentChanged is returned when the file content has changed since it was last read
28 | ErrContentChanged = errors.New("file content has changed")
29 |
30 | // ErrInvalidRange is returned when an invalid range is specified
31 | ErrInvalidRange = errors.New("invalid range specified")
32 |
33 | // ErrInvalidEncoding is returned when an unsupported encoding is specified
34 | ErrInvalidEncoding = errors.New("invalid encoding specified")
35 | )
36 |
37 | // LineRange represents a range of lines in a file
38 | type LineRange struct {
39 | Start int
40 | End int
41 | }
42 |
43 | // Service provides file operation methods
44 | type Service struct {
45 | defaultEncoding string
46 | autoDetectEncoding bool
47 | }
48 |
49 | // NewService creates a new file service
50 | func NewService(defaultEncoding string, autoDetect bool) *Service {
51 | if defaultEncoding == "" {
52 | defaultEncoding = "utf-8"
53 | }
54 |
55 | return &Service{
56 | defaultEncoding: defaultEncoding,
57 | autoDetectEncoding: autoDetect,
58 | }
59 | }
60 |
61 | // ReadLines reads lines from a file with optional range specification
62 | func (s *Service) ReadLines(filePath string, encName string, ranges ...LineRange) ([]string, string, error) {
63 | // Validate file existence
64 | if _, err := os.Stat(filePath); os.IsNotExist(err) {
65 | return nil, "", ErrFileNotFound
66 | }
67 |
68 | // Handle automatic encoding detection
69 | var enc encoding.Encoding
70 | var err error
71 |
72 | if strings.ToLower(encName) == "auto" {
73 | // Attempt to detect the encoding
74 | enc, encName, err = s.DetectEncoding(filePath)
75 | if err != nil {
76 | // Fall back to default encoding on detection failure
77 | enc, err = s.getEncoding(s.defaultEncoding)
78 | if err != nil {
79 | return nil, "", err
80 | }
81 | }
82 | } else {
83 | // Get the encoding specified by the user
84 | enc, err = s.getEncoding(encName)
85 | if err != nil {
86 | return nil, "", err
87 | }
88 | }
89 |
90 | // Open the file
91 | file, err := os.Open(filePath)
92 | if err != nil {
93 | return nil, "", fmt.Errorf("failed to open file: %w", err)
94 | }
95 | defer file.Close()
96 |
97 | // Read the entire file and calculate its hash
98 | content, err := io.ReadAll(file)
99 | if err != nil {
100 | return nil, "", fmt.Errorf("failed to read file: %w", err)
101 | }
102 |
103 | // Calculate hash of the content
104 | hash := sha256.Sum256(content)
105 | hashStr := hex.EncodeToString(hash[:])
106 |
107 | // Decode content if needed
108 | var decodedContent []byte
109 | if enc != nil {
110 | decoder := enc.NewDecoder()
111 | decodedContent, err = decoder.Bytes(content)
112 | if err != nil {
113 | return nil, "", fmt.Errorf("failed to decode content: %w", err)
114 | }
115 | } else {
116 | decodedContent = content
117 | }
118 |
119 | // Split into lines
120 | lines := strings.Split(string(decodedContent), "\n")
121 |
122 | // If no ranges provided, return all lines
123 | if len(ranges) == 0 {
124 | return lines, hashStr, nil
125 | }
126 |
127 | // Process each range
128 | var result []string
129 | for _, r := range ranges {
130 | // Validate range
131 | if r.Start < 0 || r.End > len(lines) || r.Start > r.End {
132 | return nil, "", ErrInvalidRange
133 | }
134 |
135 | // Add lines from this range
136 | result = append(result, lines[r.Start-1:r.End]...)
137 | }
138 |
139 | return result, hashStr, nil
140 | }
141 |
142 | // EditLines edits lines in a file with hash validation
143 | func (s *Service) EditLines(filePath string, oldHash string, encName string, lineEdits map[int]string) error {
144 | // Validate file existence
145 | if _, err := os.Stat(filePath); os.IsNotExist(err) {
146 | return ErrFileNotFound
147 | }
148 |
149 | // Handle automatic encoding detection
150 | var enc encoding.Encoding
151 | var err error
152 |
153 | if strings.ToLower(encName) == "auto" {
154 | // Attempt to detect the encoding
155 | enc, encName, err = s.DetectEncoding(filePath)
156 | if err != nil {
157 | // Fall back to default encoding on detection failure
158 | enc, err = s.getEncoding(s.defaultEncoding)
159 | if err != nil {
160 | return err
161 | }
162 | }
163 | } else {
164 | // Get the encoding specified by the user
165 | enc, err = s.getEncoding(encName)
166 | if err != nil {
167 | return err
168 | }
169 | }
170 |
171 | // Read current file content
172 | file, err := os.ReadFile(filePath)
173 | if err != nil {
174 | return fmt.Errorf("failed to read file: %w", err)
175 | }
176 |
177 | // Verify hash if provided
178 | if oldHash != "" {
179 | hash := sha256.Sum256(file)
180 | currentHash := hex.EncodeToString(hash[:])
181 |
182 | if currentHash != oldHash {
183 | return ErrContentChanged
184 | }
185 | }
186 |
187 | // Decode content if needed
188 | var decodedContent []byte
189 | if enc != nil {
190 | decoder := enc.NewDecoder()
191 | decodedContent, err = decoder.Bytes(file)
192 | if err != nil {
193 | return fmt.Errorf("failed to decode content: %w", err)
194 | }
195 | } else {
196 | decodedContent = file
197 | }
198 |
199 | // Split into lines
200 | lines := strings.Split(string(decodedContent), "\n")
201 |
202 | // Apply the edits
203 | for lineNum, newContent := range lineEdits {
204 | if lineNum < 1 || lineNum > len(lines) {
205 | return ErrInvalidRange
206 | }
207 |
208 | lines[lineNum-1] = newContent
209 | }
210 |
211 | // Combine back into a single string
212 | newContent := strings.Join(lines, "\n")
213 |
214 | // Encode the content if needed
215 | var finalContent []byte
216 | if enc != nil {
217 | encoder := enc.NewEncoder()
218 | finalContent, err = encoder.Bytes([]byte(newContent))
219 | if err != nil {
220 | return fmt.Errorf("failed to encode content: %w", err)
221 | }
222 | } else {
223 | finalContent = []byte(newContent)
224 | }
225 |
226 | // Create the directory if it doesn't exist
227 | dir := filepath.Dir(filePath)
228 | if _, err := os.Stat(dir); os.IsNotExist(err) {
229 | if err := os.MkdirAll(dir, 0755); err != nil {
230 | return fmt.Errorf("failed to create directory: %w", err)
231 | }
232 | }
233 |
234 | // Write the file
235 | err = os.WriteFile(filePath, finalContent, 0644)
236 | if err != nil {
237 | return fmt.Errorf("failed to write file: %w", err)
238 | }
239 |
240 | return nil
241 | }
242 |
243 | // AppendLines appends lines to a file
244 | func (s *Service) AppendLines(filePath string, encName string, lines []string) error {
245 | // Check if file exists for encoding detection
246 | fileExists := false
247 | if _, err := os.Stat(filePath); err == nil {
248 | fileExists = true
249 | }
250 |
251 | // Handle automatic encoding detection or get the specified encoding
252 | var enc encoding.Encoding
253 | var err error
254 |
255 | if strings.ToLower(encName) == "auto" {
256 | if fileExists {
257 | // Attempt to detect the encoding for existing files
258 | enc, encName, err = s.DetectEncoding(filePath)
259 | if err != nil {
260 | // Fall back to default encoding on detection failure
261 | enc, err = s.getEncoding(s.defaultEncoding)
262 | if err != nil {
263 | return err
264 | }
265 | }
266 | } else {
267 | // For new files, use the default encoding
268 | enc, err = s.getEncoding(s.defaultEncoding)
269 | if err != nil {
270 | return err
271 | }
272 | }
273 | } else {
274 | // Get the encoding specified by the user
275 | enc, err = s.getEncoding(encName)
276 | if err != nil {
277 | return err
278 | }
279 | }
280 |
281 | // Convert lines to a single string
282 | newContent := strings.Join(lines, "\n")
283 |
284 | // Add a newline if the file exists and doesn't end with one
285 | if _, err := os.Stat(filePath); err == nil {
286 | content, err := os.ReadFile(filePath)
287 | if err != nil {
288 | return fmt.Errorf("failed to read file: %w", err)
289 | }
290 |
291 | if len(content) > 0 && content[len(content)-1] != '\n' {
292 | newContent = "\n" + newContent
293 | }
294 | }
295 |
296 | // Encode the content if needed
297 | var finalContent []byte
298 | if enc != nil {
299 | encoder := enc.NewEncoder()
300 | finalContent, err = encoder.Bytes([]byte(newContent))
301 | if err != nil {
302 | return fmt.Errorf("failed to encode content: %w", err)
303 | }
304 | } else {
305 | finalContent = []byte(newContent)
306 | }
307 |
308 | // Create the directory if it doesn't exist
309 | dir := filepath.Dir(filePath)
310 | if _, err := os.Stat(dir); os.IsNotExist(err) {
311 | if err := os.MkdirAll(dir, 0755); err != nil {
312 | return fmt.Errorf("failed to create directory: %w", err)
313 | }
314 | }
315 |
316 | // Open file in append mode
317 | f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
318 | if err != nil {
319 | return fmt.Errorf("failed to open file: %w", err)
320 | }
321 | defer f.Close()
322 |
323 | // Write the content
324 | if _, err := f.Write(finalContent); err != nil {
325 | return fmt.Errorf("failed to append to file: %w", err)
326 | }
327 |
328 | return nil
329 | }
330 |
331 | // DeleteLines deletes lines from a file with hash validation
332 | func (s *Service) DeleteLines(filePath string, oldHash string, encName string, lineNumbers []int) error {
333 | // Validate file existence
334 | if _, err := os.Stat(filePath); os.IsNotExist(err) {
335 | return ErrFileNotFound
336 | }
337 |
338 | // Handle automatic encoding detection
339 | var enc encoding.Encoding
340 | var err error
341 |
342 | if strings.ToLower(encName) == "auto" {
343 | // Attempt to detect the encoding
344 | enc, encName, err = s.DetectEncoding(filePath)
345 | if err != nil {
346 | // Fall back to default encoding on detection failure
347 | enc, err = s.getEncoding(s.defaultEncoding)
348 | if err != nil {
349 | return err
350 | }
351 | }
352 | } else {
353 | // Get the encoding specified by the user
354 | enc, err = s.getEncoding(encName)
355 | if err != nil {
356 | return err
357 | }
358 | }
359 |
360 | // Read current file content
361 | file, err := os.ReadFile(filePath)
362 | if err != nil {
363 | return fmt.Errorf("failed to read file: %w", err)
364 | }
365 |
366 | // Verify hash if provided
367 | if oldHash != "" {
368 | hash := sha256.Sum256(file)
369 | currentHash := hex.EncodeToString(hash[:])
370 |
371 | if currentHash != oldHash {
372 | return ErrContentChanged
373 | }
374 | }
375 |
376 | // Decode content if needed
377 | var decodedContent []byte
378 | if enc != nil {
379 | decoder := enc.NewDecoder()
380 | decodedContent, err = decoder.Bytes(file)
381 | if err != nil {
382 | return fmt.Errorf("failed to decode content: %w", err)
383 | }
384 | } else {
385 | decodedContent = file
386 | }
387 |
388 | // Split into lines
389 | lines := strings.Split(string(decodedContent), "\n")
390 |
391 | // Create a map of lines to delete for O(1) lookup
392 | toDelete := make(map[int]bool)
393 | for _, lineNum := range lineNumbers {
394 | if lineNum < 1 || lineNum > len(lines) {
395 | return ErrInvalidRange
396 | }
397 | toDelete[lineNum-1] = true
398 | }
399 |
400 | // Create a new slice without the deleted lines
401 | var newLines []string
402 | for i, line := range lines {
403 | if !toDelete[i] {
404 | newLines = append(newLines, line)
405 | }
406 | }
407 |
408 | // Combine back into a single string
409 | newContent := strings.Join(newLines, "\n")
410 |
411 | // Encode the content if needed
412 | var finalContent []byte
413 | if enc != nil {
414 | encoder := enc.NewEncoder()
415 | finalContent, err = encoder.Bytes([]byte(newContent))
416 | if err != nil {
417 | return fmt.Errorf("failed to encode content: %w", err)
418 | }
419 | } else {
420 | finalContent = []byte(newContent)
421 | }
422 |
423 | // Write the file
424 | err = os.WriteFile(filePath, finalContent, 0644)
425 | if err != nil {
426 | return fmt.Errorf("failed to write file: %w", err)
427 | }
428 |
429 | return nil
430 | }
431 |
432 | // InsertLines inserts lines at a specific position with hash validation
433 | func (s *Service) InsertLines(filePath string, oldHash string, encName string, position int, newLines []string) error {
434 | // Validate file existence
435 | isNewFile := false
436 | if _, err := os.Stat(filePath); os.IsNotExist(err) {
437 | isNewFile = true
438 |
439 | // If this is a new file and position is not 1, return an error
440 | if position != 1 {
441 | return ErrInvalidRange
442 | }
443 | }
444 |
445 | // Handle automatic encoding detection or get the specified encoding
446 | var enc encoding.Encoding
447 | var err error
448 |
449 | if strings.ToLower(encName) == "auto" {
450 | if !isNewFile {
451 | // Attempt to detect the encoding for existing files
452 | enc, encName, err = s.DetectEncoding(filePath)
453 | if err != nil {
454 | // Fall back to default encoding on detection failure
455 | enc, err = s.getEncoding(s.defaultEncoding)
456 | if err != nil {
457 | return err
458 | }
459 | }
460 | } else {
461 | // For new files, use the default encoding
462 | enc, err = s.getEncoding(s.defaultEncoding)
463 | if err != nil {
464 | return err
465 | }
466 | }
467 | } else {
468 | // Get the encoding specified by the user
469 | enc, err = s.getEncoding(encName)
470 | if err != nil {
471 | return err
472 | }
473 | }
474 |
475 | var lines []string
476 |
477 | if !isNewFile {
478 | // Read current file content
479 | file, err := os.ReadFile(filePath)
480 | if err != nil {
481 | return fmt.Errorf("failed to read file: %w", err)
482 | }
483 |
484 | // Verify hash if provided
485 | if oldHash != "" {
486 | hash := sha256.Sum256(file)
487 | currentHash := hex.EncodeToString(hash[:])
488 |
489 | if currentHash != oldHash {
490 | return ErrContentChanged
491 | }
492 | }
493 |
494 | // Decode content if needed
495 | var decodedContent []byte
496 | if enc != nil {
497 | decoder := enc.NewDecoder()
498 | decodedContent, err = decoder.Bytes(file)
499 | if err != nil {
500 | return fmt.Errorf("failed to decode content: %w", err)
501 | }
502 | } else {
503 | decodedContent = file
504 | }
505 |
506 | // Split into lines
507 | lines = strings.Split(string(decodedContent), "\n")
508 |
509 | // Validate position
510 | if position < 1 || position > len(lines)+1 {
511 | return ErrInvalidRange
512 | }
513 | } else {
514 | lines = []string{}
515 | }
516 |
517 | // Insert the new lines
518 | var resultLines []string
519 |
520 | if position == 1 {
521 | resultLines = append(newLines, lines...)
522 | } else if position == len(lines)+1 {
523 | resultLines = append(lines, newLines...)
524 | } else {
525 | resultLines = append(resultLines, lines[:position-1]...)
526 | resultLines = append(resultLines, newLines...)
527 | resultLines = append(resultLines, lines[position-1:]...)
528 | }
529 |
530 | // Combine back into a single string
531 | newContent := strings.Join(resultLines, "\n")
532 |
533 | // Encode the content if needed
534 | var finalContent []byte
535 | if enc != nil {
536 | encoder := enc.NewEncoder()
537 | finalContent, err = encoder.Bytes([]byte(newContent))
538 | if err != nil {
539 | return fmt.Errorf("failed to encode content: %w", err)
540 | }
541 | } else {
542 | finalContent = []byte(newContent)
543 | }
544 |
545 | // Create the directory if it doesn't exist
546 | dir := filepath.Dir(filePath)
547 | if _, err := os.Stat(dir); os.IsNotExist(err) {
548 | if err := os.MkdirAll(dir, 0755); err != nil {
549 | return fmt.Errorf("failed to create directory: %w", err)
550 | }
551 | }
552 |
553 | // Write the file
554 | err = os.WriteFile(filePath, finalContent, 0644)
555 | if err != nil {
556 | return fmt.Errorf("failed to write file: %w", err)
557 | }
558 |
559 | return nil
560 | }
561 |
562 | // DetectEncoding attempts to detect the encoding of a file
563 | func (s *Service) DetectEncoding(filePath string) (encoding.Encoding, string, error) {
564 | // Open the file
565 | file, err := os.Open(filePath)
566 | if err != nil {
567 | return nil, "", fmt.Errorf("failed to open file: %w", err)
568 | }
569 | defer file.Close()
570 |
571 | // Read a small portion of the file for detection
572 | var buf [1024]byte
573 | n, err := file.Read(buf[:])
574 | if err != nil && err != io.EOF {
575 | return nil, "", fmt.Errorf("failed to read file: %w", err)
576 | }
577 |
578 | // Reset the file pointer to the beginning
579 | _, err = file.Seek(0, 0)
580 | if err != nil {
581 | return nil, "", fmt.Errorf("failed to reset file position: %w", err)
582 | }
583 |
584 | // Detect encoding
585 | e, name, _ := charset.DetermineEncoding(buf[:n], "")
586 |
587 | // Normalize name to our standard format
588 | normalizedName := s.normalizeEncodingName(name)
589 |
590 | return e, normalizedName, nil
591 | }
592 |
593 | // normalizeEncodingName normalizes encoding names to our standard format
594 | func (s *Service) normalizeEncodingName(name string) string {
595 | name = strings.ToLower(name)
596 | switch name {
597 | case "utf-8", "utf8":
598 | return "utf-8"
599 | case "windows-1252", "cp1252":
600 | return "windows-1252"
601 | case "iso-8859-1":
602 | return "latin1"
603 | case "shift_jis", "shift-jis", "shiftjis":
604 | return "shift_jis"
605 | case "gbk", "gb18030", "gb2312":
606 | return "gbk"
607 | case "big5", "big5-hkscs":
608 | return "big5"
609 | case "euc-jp":
610 | return "euc-jp"
611 | case "euc-kr":
612 | return "euc-kr"
613 | default:
614 | return name
615 | }
616 | }
617 |
618 | // GetEncoding returns the appropriate encoding based on the provided name
619 | // If name is "auto", it will attempt to detect the encoding
620 | func (s *Service) getEncoding(encName string) (encoding.Encoding, error) {
621 | if encName == "" {
622 | encName = s.defaultEncoding
623 | }
624 |
625 | switch strings.ToLower(encName) {
626 | case "utf-8", "utf8":
627 | return nil, nil // No encoding/decoding needed for UTF-8
628 | case "utf-16", "utf16":
629 | return unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), nil
630 | case "utf-16be", "utf16be":
631 | return unicode.UTF16(unicode.BigEndian, unicode.UseBOM), nil
632 | case "utf-16le", "utf16le":
633 | return unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), nil
634 |
635 | // Japanese encodings
636 | case "shift_jis", "shift-jis", "shiftjis":
637 | return japanese.ShiftJIS, nil
638 | case "euc-jp":
639 | return japanese.EUCJP, nil
640 | case "iso-2022-jp":
641 | return japanese.ISO2022JP, nil
642 |
643 | // Chinese encodings
644 | case "gbk", "gb18030", "gb2312":
645 | return simplifiedchinese.GBK, nil
646 | case "big5", "big5-hkscs":
647 | return traditionalchinese.Big5, nil
648 |
649 | // Korean encodings
650 | case "euc-kr":
651 | return korean.EUCKR, nil
652 |
653 | // Western encodings
654 | case "iso-8859-1", "latin1":
655 | return charmap.ISO8859_1, nil
656 | case "iso-8859-2", "latin2":
657 | return charmap.ISO8859_2, nil
658 | case "iso-8859-3", "latin3":
659 | return charmap.ISO8859_3, nil
660 | case "iso-8859-4", "latin4":
661 | return charmap.ISO8859_4, nil
662 | case "iso-8859-5":
663 | return charmap.ISO8859_5, nil
664 | case "iso-8859-6":
665 | return charmap.ISO8859_6, nil
666 | case "iso-8859-7":
667 | return charmap.ISO8859_7, nil
668 | case "iso-8859-8":
669 | return charmap.ISO8859_8, nil
670 | case "iso-8859-9", "latin5":
671 | return charmap.ISO8859_9, nil
672 | case "iso-8859-10", "latin6":
673 | return charmap.ISO8859_10, nil
674 | case "iso-8859-13", "latin7":
675 | return charmap.ISO8859_13, nil
676 | case "iso-8859-14", "latin8":
677 | return charmap.ISO8859_14, nil
678 | case "iso-8859-15", "latin9":
679 | return charmap.ISO8859_15, nil
680 | case "iso-8859-16":
681 | return charmap.ISO8859_16, nil
682 |
683 | // Windows encodings
684 | case "windows-1250":
685 | return charmap.Windows1250, nil
686 | case "windows-1251":
687 | return charmap.Windows1251, nil
688 | case "windows-1252", "cp1252":
689 | return charmap.Windows1252, nil
690 | case "windows-1253":
691 | return charmap.Windows1253, nil
692 | case "windows-1254":
693 | return charmap.Windows1254, nil
694 | case "windows-1255":
695 | return charmap.Windows1255, nil
696 | case "windows-1256":
697 | return charmap.Windows1256, nil
698 | case "windows-1257":
699 | return charmap.Windows1257, nil
700 | case "windows-1258":
701 | return charmap.Windows1258, nil
702 |
703 | default:
704 | return nil, ErrInvalidEncoding
705 | }
706 | }
```
--------------------------------------------------------------------------------
/internal/mcp/tools.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "time"
11 |
12 | "codeberg.org/mutker/mcp-todo-server/internal/config"
13 | "codeberg.org/mutker/mcp-todo-server/internal/services/changelog"
14 | "codeberg.org/mutker/mcp-todo-server/internal/services/todo"
15 | "github.com/mark3labs/mcp-go/mcp"
16 | "github.com/mark3labs/mcp-go/server"
17 | )
18 |
19 | // This function is now defined in server.go
20 |
21 | // registerTodoTools registers TODO.md related tools
22 | func RegisterTools(ctx context.Context, s *server.MCPServer, cfg *config.Config) {
23 | todoService := todo.NewService(cfg)
24 | changelogService := changelog.NewService(cfg)
25 |
26 | // Get all tasks
27 | s.AddTool(mcp.NewTool("get-todo-tasks",
28 | mcp.WithDescription("Get all tasks from TODO.md"),
29 | ),
30 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
31 | todos, err := todoService.GetAll()
32 | if err != nil {
33 | return mcp.NewToolResultError(fmt.Sprintf("failed to get todos: %w", err)), nil
34 | }
35 | result, err := newToolResultJSON(todos)
36 | return result, err
37 | },
38 | )
39 |
40 | // Get tasks for a specific version
41 | s.AddTool(mcp.NewTool("get-todo-tasks-by-version",
42 | mcp.WithDescription("Get tasks for a specific version from TODO.md"),
43 | mcp.WithString("version",
44 | mcp.PropertyOption(mcp.Required()),
45 | mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
46 | ),
47 | ),
48 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
49 | version, ok := request.Params.Arguments["version"].(string)
50 | if !ok {
51 | return mcp.NewToolResultError("version parameter must be a string"), nil
52 | }
53 |
54 | todos, err := todoService.GetByVersion(version)
55 | if err != nil {
56 | return mcp.NewToolResultError(fmt.Sprintf("failed to get todos for version %s: %w", version, err)), nil
57 | }
58 | if todos == nil {
59 | return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
60 | }
61 | result, err := newToolResultJSON(todos)
62 | return result, err
63 | },
64 | )
65 |
66 | // Add a new task
67 | s.AddTool(mcp.NewTool("add-todo-task",
68 | mcp.WithDescription("Add a new task to TODO.md"),
69 | mcp.WithString("version",
70 | mcp.PropertyOption(mcp.Required()),
71 | mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
72 | ),
73 | mcp.WithString("description",
74 | mcp.PropertyOption(mcp.Required()),
75 | mcp.PropertyOption(mcp.Description("Task description")),
76 | ),
77 | mcp.WithString("parent_id",
78 | mcp.PropertyOption(mcp.Description("ID of parent task (for subtasks)")),
79 | ),
80 | ),
81 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
82 | version, ok := request.Params.Arguments["version"].(string)
83 | if !ok {
84 | return mcp.NewToolResultError("version parameter must be a string"), nil
85 | }
86 |
87 | description, ok := request.Params.Arguments["description"].(string)
88 | if !ok {
89 | return mcp.NewToolResultError("description parameter must be a string"), nil
90 | }
91 |
92 | var parentID string
93 | if parent, ok := request.Params.Arguments["parent_id"].(string); ok {
94 | parentID = parent
95 | }
96 |
97 | taskID, err := todoService.AddTask(version, description, parentID)
98 | if err != nil {
99 | return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
100 | }
101 |
102 | result, err := newToolResultJSON(map[string]string{"id": taskID})
103 | return result, err
104 | },
105 | )
106 |
107 | // Update an existing task
108 | toolSchema := map[string]interface{}{
109 | "type": "object",
110 | "properties": map[string]interface{}{
111 | "id": map[string]interface{}{
112 | "type": "string",
113 | "description": "Task ID",
114 | },
115 | "completed": map[string]interface{}{
116 | "type": "boolean",
117 | "description": "Task completion status",
118 | },
119 | "description": map[string]interface{}{
120 | "type": "string",
121 | "description": "Updated task description",
122 | },
123 | },
124 | "required": []string{"id"},
125 | }
126 | schemaBytes, _ := json.Marshal(toolSchema)
127 | s.AddTool(mcp.NewToolWithRawSchema("update-todo-task",
128 | "Update an existing task in TODO.md",
129 | json.RawMessage(schemaBytes)),
130 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
131 | taskID, ok := request.Params.Arguments["id"].(string)
132 | if !ok {
133 | return mcp.NewToolResultError("id parameter must be a string"), nil
134 | }
135 |
136 | var completed *bool
137 | if c, ok := request.Params.Arguments["completed"].(bool); ok {
138 | completed = &c
139 | }
140 |
141 | var description *string
142 | if d, ok := request.Params.Arguments["description"].(string); ok {
143 | description = &d
144 | }
145 |
146 | if completed == nil && description == nil {
147 | return mcp.NewToolResultError("at least one of completed or description must be provided"), nil
148 | }
149 |
150 | err := todoService.UpdateTask(taskID, completed, description)
151 | if err != nil {
152 | if err == todo.ErrTaskNotFound {
153 | return mcp.NewToolResultError(fmt.Sprintf("task not found: %s", taskID)), nil
154 | }
155 | return mcp.NewToolResultError(fmt.Sprintf("failed to update task: %w", err)), nil
156 | }
157 |
158 | result, err := newToolResultJSON(map[string]bool{"success": true})
159 | return result, err
160 | },
161 | )
162 |
163 | // Add a new version section
164 | s.AddTool(mcp.NewTool("add-todo-version",
165 | mcp.WithDescription("Add a new version section to TODO.md"),
166 | mcp.WithString("version",
167 | mcp.PropertyOption(mcp.Required()),
168 | mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
169 | ),
170 | ),
171 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
172 | version, ok := request.Params.Arguments["version"].(string)
173 | if !ok {
174 | return mcp.NewToolResultError("version parameter must be a string"), nil
175 | }
176 |
177 | err := todoService.AddVersion(version)
178 | if err != nil {
179 | if err == todo.ErrVersionExists {
180 | return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
181 | }
182 | return mcp.NewToolResultError(fmt.Sprintf("failed to add version: %w", err)), nil
183 | }
184 |
185 | result, err := newToolResultJSON(map[string]bool{"success": true})
186 | return result, err
187 | },
188 | )
189 |
190 | // Import and format an existing TODO.md
191 | s.AddTool(mcp.NewTool("import-todo",
192 | mcp.WithDescription("Import and format an existing TODO.md file"),
193 | mcp.WithString("source_path",
194 | mcp.PropertyOption(mcp.Required()),
195 | mcp.PropertyOption(mcp.Description("Path to the source TODO.md file")),
196 | ),
197 | ),
198 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
199 | sourcePath, ok := request.Params.Arguments["source_path"].(string)
200 | if !ok {
201 | return mcp.NewToolResultError("source_path parameter must be a string"), nil
202 | }
203 |
204 | // Create an absolute path if a relative path is provided
205 | if !filepath.IsAbs(sourcePath) {
206 | cwd, err := os.Getwd()
207 | if err != nil {
208 | return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
209 | }
210 | sourcePath = filepath.Join(cwd, sourcePath)
211 | }
212 |
213 | // Read the source file
214 | content, err := os.ReadFile(sourcePath)
215 | if err != nil {
216 | return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
217 | }
218 |
219 | // Split into lines
220 | lines := strings.Split(string(content), "\n")
221 |
222 | // Parse the content
223 | todoList, err := todoService.GetAll()
224 | if err != nil {
225 | return mcp.NewToolResultError(fmt.Sprintf("failed to load existing TODO file: %w", err)), nil
226 | }
227 |
228 | // Only proceed if the current TODO.md is empty
229 | if len(todoList.Versions) > 0 {
230 | return mcp.NewToolResultError("TODO.md already exists and has content, cannot import"), nil
231 | }
232 |
233 | // Process each line
234 | var currentVersion string
235 | var tasks []struct {
236 | Version string
237 | Description string
238 | Completed bool
239 | }
240 |
241 | for _, line := range lines {
242 | // Skip empty lines and title
243 | if line == "" || line == "# TODO" {
244 | continue
245 | }
246 |
247 | // Check for version headers
248 | if strings.HasPrefix(line, "## ") {
249 | versionStr := strings.TrimPrefix(line, "## ")
250 | // Handle both v1.0.0 and 1.0.0 formats
251 | if strings.HasPrefix(versionStr, "v") {
252 | currentVersion = strings.TrimPrefix(versionStr, "v")
253 | } else {
254 | currentVersion = versionStr
255 | }
256 |
257 | if currentVersion != "" {
258 | // Add the version
259 | err := todoService.AddVersion(currentVersion)
260 | if err != nil && err != todo.ErrVersionExists {
261 | return mcp.NewToolResultError(fmt.Sprintf("failed to add version %s: %w", currentVersion, err)), nil
262 | }
263 | }
264 | continue
265 | }
266 |
267 | // Check for task lines
268 | if strings.Contains(line, "- [ ]") || strings.Contains(line, "- [x]") {
269 | completed := strings.Contains(line, "- [x]")
270 |
271 | var description string
272 | if completed {
273 | description = strings.TrimSpace(strings.Split(line, "- [x]")[1])
274 | } else {
275 | description = strings.TrimSpace(strings.Split(line, "- [ ]")[1])
276 | }
277 |
278 | if currentVersion != "" && description != "" {
279 | tasks = append(tasks, struct {
280 | Version string
281 | Description string
282 | Completed bool
283 | }{
284 | Version: currentVersion,
285 | Description: description,
286 | Completed: completed,
287 | })
288 | }
289 | }
290 | }
291 |
292 | // Add all tasks
293 | for _, t := range tasks {
294 | taskID, err := todoService.AddTask(t.Version, t.Description, "")
295 | if err != nil {
296 | return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
297 | }
298 |
299 | if t.Completed {
300 | completed := true
301 | err = todoService.UpdateTask(taskID, &completed, nil)
302 | if err != nil {
303 | return mcp.NewToolResultError(fmt.Sprintf("failed to mark task as completed: %w", err)), nil
304 | }
305 | }
306 | }
307 |
308 | result, err := newToolResultJSON(map[string]interface{}{
309 | "success": true,
310 | "versions_added": len(todoList.Versions),
311 | "tasks_added": len(tasks),
312 | })
313 | return result, err
314 | },
315 | )
316 |
317 | // Get all changelog items
318 | s.AddTool(mcp.NewTool("get-changelog",
319 | mcp.WithDescription("Get all changelog entries"),
320 | ),
321 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
322 | changelogEntries, err := changelogService.GetAll()
323 | if err != nil {
324 | return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entries: %w", err)), nil
325 | }
326 | result, err := newToolResultJSON(changelogEntries)
327 | return result, err
328 | },
329 | )
330 |
331 | // Get changelog items for a specific version
332 | s.AddTool(mcp.NewTool("get-changelog-by-version",
333 | mcp.WithDescription("Get changelog entries for a specific version"),
334 | mcp.WithString("version",
335 | mcp.PropertyOption(mcp.Required()),
336 | mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
337 | ),
338 | ),
339 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
340 | version, ok := request.Params.Arguments["version"].(string)
341 | if !ok {
342 | return mcp.NewToolResultError("version parameter must be a string"), nil
343 | }
344 |
345 | entry, err := changelogService.GetByVersion(version)
346 | if err != nil {
347 | return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entry for version %s: %w", version, err)), nil
348 | }
349 | if entry == nil {
350 | return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
351 | }
352 | result, err := newToolResultJSON(entry)
353 | return result, err
354 | },
355 | )
356 |
357 | // Add a new changelog version entry - using raw schema
358 | changelogSchema := map[string]interface{}{
359 | "type": "object",
360 | "properties": map[string]interface{}{
361 | "version": map[string]interface{}{
362 | "type": "string",
363 | "description": "Version string (e.g., '1.0.0')",
364 | },
365 | "date": map[string]interface{}{
366 | "type": "string",
367 | "description": "Release date (YYYY-MM-DD format)",
368 | },
369 | "added": map[string]interface{}{
370 | "type": "array",
371 | "description": "List of new features added",
372 | "items": map[string]interface{}{
373 | "type": "string",
374 | },
375 | },
376 | "changed": map[string]interface{}{
377 | "type": "array",
378 | "description": "List of changes to existing functionality",
379 | "items": map[string]interface{}{
380 | "type": "string",
381 | },
382 | },
383 | "deprecated": map[string]interface{}{
384 | "type": "array",
385 | "description": "List of deprecated features",
386 | "items": map[string]interface{}{
387 | "type": "string",
388 | },
389 | },
390 | "removed": map[string]interface{}{
391 | "type": "array",
392 | "description": "List of removed features",
393 | "items": map[string]interface{}{
394 | "type": "string",
395 | },
396 | },
397 | "fixed": map[string]interface{}{
398 | "type": "array",
399 | "description": "List of bug fixes",
400 | "items": map[string]interface{}{
401 | "type": "string",
402 | },
403 | },
404 | "security": map[string]interface{}{
405 | "type": "array",
406 | "description": "List of security fixes",
407 | "items": map[string]interface{}{
408 | "type": "string",
409 | },
410 | },
411 | },
412 | "required": []string{"version"},
413 | }
414 | changelogSchemaBytes, _ := json.Marshal(changelogSchema)
415 | s.AddTool(mcp.NewToolWithRawSchema("add-changelog-entry",
416 | "Add a new changelog version entry",
417 | json.RawMessage(changelogSchemaBytes)),
418 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
419 | version, ok := request.Params.Arguments["version"].(string)
420 | if !ok {
421 | return mcp.NewToolResultError("version parameter must be a string"), nil
422 | }
423 |
424 | date, ok := request.Params.Arguments["date"].(string)
425 | if !ok {
426 | // Default to today's date
427 | date = time.Now().Format("2006-01-02")
428 | }
429 |
430 | // Process content sections
431 | content := &changelog.ChangelogContent{}
432 |
433 | if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
434 | content.Added = added
435 | }
436 | if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
437 | content.Changed = changed
438 | }
439 | if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
440 | content.Deprecated = deprecated
441 | }
442 | if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
443 | content.Removed = removed
444 | }
445 | if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
446 | content.Fixed = fixed
447 | }
448 | if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
449 | content.Security = security
450 | }
451 |
452 | err := changelogService.AddEntry(version, date, content)
453 | if err != nil {
454 | if err == changelog.ErrVersionExists {
455 | return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
456 | }
457 | return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
458 | }
459 |
460 | result, err := newToolResultJSON(map[string]bool{"success": true})
461 | return result, err
462 | },
463 | )
464 |
465 | // Update existing changelog entry - using raw schema
466 | updateChangelogSchema := map[string]interface{}{
467 | "type": "object",
468 | "properties": map[string]interface{}{
469 | "version": map[string]interface{}{
470 | "type": "string",
471 | "description": "Version string (e.g., '1.0.0')",
472 | },
473 | "date": map[string]interface{}{
474 | "type": "string",
475 | "description": "Updated release date (YYYY-MM-DD format)",
476 | },
477 | "added": map[string]interface{}{
478 | "type": "array",
479 | "description": "Updated list of new features added",
480 | "items": map[string]interface{}{
481 | "type": "string",
482 | },
483 | },
484 | "changed": map[string]interface{}{
485 | "type": "array",
486 | "description": "Updated list of changes to existing functionality",
487 | "items": map[string]interface{}{
488 | "type": "string",
489 | },
490 | },
491 | "deprecated": map[string]interface{}{
492 | "type": "array",
493 | "description": "Updated list of deprecated features",
494 | "items": map[string]interface{}{
495 | "type": "string",
496 | },
497 | },
498 | "removed": map[string]interface{}{
499 | "type": "array",
500 | "description": "Updated list of removed features",
501 | "items": map[string]interface{}{
502 | "type": "string",
503 | },
504 | },
505 | "fixed": map[string]interface{}{
506 | "type": "array",
507 | "description": "Updated list of bug fixes",
508 | "items": map[string]interface{}{
509 | "type": "string",
510 | },
511 | },
512 | "security": map[string]interface{}{
513 | "type": "array",
514 | "description": "Updated list of security fixes",
515 | "items": map[string]interface{}{
516 | "type": "string",
517 | },
518 | },
519 | },
520 | "required": []string{"version"},
521 | }
522 | updateChangelogSchemaBytes, _ := json.Marshal(updateChangelogSchema)
523 | s.AddTool(mcp.NewToolWithRawSchema("update-changelog-entry",
524 | "Update an existing changelog entry",
525 | json.RawMessage(updateChangelogSchemaBytes)),
526 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
527 | version, ok := request.Params.Arguments["version"].(string)
528 | if !ok {
529 | return mcp.NewToolResultError("version parameter must be a string"), nil
530 | }
531 |
532 | date, ok := request.Params.Arguments["date"].(string)
533 | if !ok {
534 | date = ""
535 | }
536 |
537 | // Process content sections
538 | content := &changelog.ChangelogContent{}
539 |
540 | if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
541 | content.Added = added
542 | }
543 | if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
544 | content.Changed = changed
545 | }
546 | if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
547 | content.Deprecated = deprecated
548 | }
549 | if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
550 | content.Removed = removed
551 | }
552 | if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
553 | content.Fixed = fixed
554 | }
555 | if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
556 | content.Security = security
557 | }
558 |
559 | err := changelogService.UpdateEntry(version, date, content)
560 | if err != nil {
561 | if err == changelog.ErrVersionNotFound {
562 | return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
563 | }
564 | return mcp.NewToolResultError(fmt.Sprintf("failed to update changelog entry: %w", err)), nil
565 | }
566 | result, err := newToolResultJSON(map[string]bool{"success": true})
567 | return result, err
568 | },
569 | )
570 |
571 | // Import and format an existing CHANGELOG.md
572 | s.AddTool(mcp.NewTool("import-changelog",
573 | mcp.WithDescription("Import and format an existing CHANGELOG.md file"),
574 | mcp.WithString("source_path",
575 | mcp.PropertyOption(mcp.Required()),
576 | mcp.PropertyOption(mcp.Description("Path to the source CHANGELOG.md file")),
577 | ),
578 | ),
579 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
580 | sourcePath, ok := request.Params.Arguments["source_path"].(string)
581 | if !ok {
582 | return mcp.NewToolResultError("source_path parameter must be a string"), nil
583 | }
584 |
585 | // Create an absolute path if a relative path is provided
586 | if !filepath.IsAbs(sourcePath) {
587 | cwd, err := os.Getwd()
588 | if err != nil {
589 | return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
590 | }
591 | sourcePath = filepath.Join(cwd, sourcePath)
592 | }
593 |
594 | // Read the source file
595 | content, err := os.ReadFile(sourcePath)
596 | if err != nil {
597 | return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
598 | }
599 |
600 | // Split into lines
601 | lines := strings.Split(string(content), "\n")
602 |
603 | // Parse the content
604 | changelogEntries, err := changelogService.GetAll()
605 | if err != nil {
606 | return mcp.NewToolResultError(fmt.Sprintf("failed to load existing CHANGELOG file: %w", err)), nil
607 | }
608 |
609 | // Only proceed if the current CHANGELOG.md is empty
610 | if len(changelogEntries.Versions) > 0 {
611 | return mcp.NewToolResultError("CHANGELOG.md already exists and has content, cannot import"), nil
612 | }
613 |
614 | // Process the content
615 | var currentVersion string
616 | var currentDate string
617 | var currentSection string
618 | var entriesAdded int
619 |
620 | var added []string
621 | var changed []string
622 | var deprecated []string
623 | var removed []string
624 | var fixed []string
625 | var security []string
626 |
627 | for _, line := range lines {
628 | // Skip empty lines and title
629 | if line == "" || line == "# Changelog" {
630 | continue
631 | }
632 |
633 | // Check for version headers
634 | if strings.HasPrefix(line, "## [") && strings.Contains(line, "] - ") {
635 | // Save the previous version if exists
636 | if currentVersion != "" {
637 | content := &changelog.ChangelogContent{
638 | Added: added,
639 | Changed: changed,
640 | Deprecated: deprecated,
641 | Removed: removed,
642 | Fixed: fixed,
643 | Security: security,
644 | }
645 |
646 | err := changelogService.AddEntry(currentVersion, currentDate, content)
647 | if err != nil && err != changelog.ErrVersionExists {
648 | return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
649 | }
650 | entriesAdded++
651 |
652 | // Reset for next version
653 | added = nil
654 | changed = nil
655 | deprecated = nil
656 | removed = nil
657 | fixed = nil
658 | security = nil
659 | }
660 |
661 | // Parse version and date
662 | parts := strings.Split(line, "] - ")
663 | versionStr := strings.TrimPrefix(parts[0], "## [")
664 | currentVersion = versionStr
665 | currentDate = parts[1]
666 | currentSection = ""
667 | continue
668 | }
669 |
670 | // Check for section headers
671 | if strings.HasPrefix(line, "### ") {
672 | currentSection = strings.ToLower(strings.TrimPrefix(line, "### "))
673 | continue
674 | }
675 |
676 | // Process list items
677 | if strings.HasPrefix(line, "- ") && currentSection != "" {
678 | item := strings.TrimPrefix(line, "- ")
679 |
680 | switch currentSection {
681 | case "added":
682 | added = append(added, item)
683 | case "changed":
684 | changed = append(changed, item)
685 | case "deprecated":
686 | deprecated = append(deprecated, item)
687 | case "removed":
688 | removed = append(removed, item)
689 | case "fixed":
690 | fixed = append(fixed, item)
691 | case "security":
692 | security = append(security, item)
693 | }
694 | }
695 | }
696 |
697 | // Add the last version if exists
698 | if currentVersion != "" {
699 | content := &changelog.ChangelogContent{
700 | Added: added,
701 | Changed: changed,
702 | Deprecated: deprecated,
703 | Removed: removed,
704 | Fixed: fixed,
705 | Security: security,
706 | }
707 |
708 | err := changelogService.AddEntry(currentVersion, currentDate, content)
709 | if err != nil && err != changelog.ErrVersionExists {
710 | return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
711 | }
712 | entriesAdded++
713 | }
714 | result, err := newToolResultJSON(map[string]interface{}{
715 | "success": true,
716 | "entries_added": entriesAdded,
717 | })
718 | return result, err
719 | },
720 | )
721 |
722 | // Generate a new CHANGELOG.md based on completed tasks in TODO.md
723 | s.AddTool(mcp.NewTool("generate-changelog-from-todo",
724 | mcp.WithDescription("Generate a new CHANGELOG.md entry based on completed tasks in TODO.md"),
725 | mcp.WithString("version",
726 | mcp.PropertyOption(mcp.Required()),
727 | mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
728 | ),
729 | mcp.WithString("date",
730 | mcp.PropertyOption(mcp.Description("Release date (YYYY-MM-DD format)")),
731 | ),
732 | ),
733 | func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
734 | version, ok := request.Params.Arguments["version"].(string)
735 | if !ok {
736 | return mcp.NewToolResultError("version parameter must be a string"), nil
737 | }
738 |
739 | date, ok := request.Params.Arguments["date"].(string)
740 | if !ok {
741 | // Default to today's date
742 | date = time.Now().Format("2006-01-02")
743 | }
744 |
745 | // Get tasks for the specified version
746 | versionTasks, err := todoService.GetByVersion(version)
747 | if err != nil {
748 | return mcp.NewToolResultError(fmt.Sprintf("failed to get tasks for version %s: %w", version, err)), nil
749 | }
750 |
751 | if versionTasks == nil {
752 | return mcp.NewToolResultError(fmt.Sprintf("version not found in TODO.md: %s", version)), nil
753 | }
754 |
755 | // Check if this version already exists in CHANGELOG
756 | existingEntry, err := changelogService.GetByVersion(version)
757 | if err != nil {
758 | return mcp.NewToolResultError(fmt.Sprintf("failed to check if changelog entry exists: %w", err)), nil
759 | }
760 |
761 | if existingEntry != nil {
762 | return mcp.NewToolResultError(fmt.Sprintf("changelog entry already exists for version %s", version)), nil
763 | }
764 |
765 | // Categorize completed tasks
766 | var added []string
767 | var changed []string
768 | var fixed []string
769 |
770 | // Helper function to process tasks recursively
771 | var processTasks func(tasks []*todo.Task)
772 | processTasks = func(tasks []*todo.Task) {
773 | for _, task := range tasks {
774 | if task.Completed {
775 | // Categorize based on common prefixes/keywords
776 | desc := task.Description
777 | lowerDesc := strings.ToLower(desc)
778 |
779 | if strings.HasPrefix(lowerDesc, "add") ||
780 | strings.HasPrefix(lowerDesc, "implement") ||
781 | strings.HasPrefix(lowerDesc, "create") {
782 | added = append(added, desc)
783 | } else if strings.HasPrefix(lowerDesc, "update") ||
784 | strings.HasPrefix(lowerDesc, "change") ||
785 | strings.HasPrefix(lowerDesc, "modify") ||
786 | strings.HasPrefix(lowerDesc, "enhance") ||
787 | strings.HasPrefix(lowerDesc, "improve") {
788 | changed = append(changed, desc)
789 | } else if strings.HasPrefix(lowerDesc, "fix") ||
790 | strings.HasPrefix(lowerDesc, "correct") ||
791 | strings.HasPrefix(lowerDesc, "resolve") {
792 | fixed = append(fixed, desc)
793 | } else {
794 | // Default to Added if cannot categorize
795 | added = append(added, desc)
796 | }
797 | }
798 |
799 | // Process subtasks
800 | if len(task.SubTasks) > 0 {
801 | processTasks(task.SubTasks)
802 | }
803 | }
804 | }
805 |
806 | processTasks(versionTasks.Tasks)
807 |
808 | // Create changelog entry
809 | content := &changelog.ChangelogContent{
810 | Added: added,
811 | Changed: changed,
812 | Fixed: fixed,
813 | }
814 |
815 | err = changelogService.AddEntry(version, date, content)
816 | if err != nil {
817 | return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
818 | }
819 |
820 | result, err := newToolResultJSON(map[string]interface{}{
821 | "success": true,
822 | "added_items": len(added),
823 | "changed_items": len(changed),
824 | "fixed_items": len(fixed),
825 | })
826 | return result, err
827 | },
828 | )
829 | }
830 |
831 | // Helper function to extract string arrays from the arguments
832 | func getStringArray(args map[string]any, key string) ([]string, bool) {
833 | if val, ok := args[key]; ok {
834 | if arr, ok := val.([]interface{}); ok {
835 | result := make([]string, 0, len(arr))
836 | for _, item := range arr {
837 | if strItem, ok := item.(string); ok {
838 | result = append(result, strItem)
839 | }
840 | }
841 | return result, true
842 | } else if arrStr, ok := val.([]string); ok {
843 | return arrStr, true
844 | }
845 | }
846 | return nil, false
847 | }
848 |
```