# 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:
--------------------------------------------------------------------------------
```
build/
dist/
notes/
tmp/
```
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
```yaml
project_name: mcp-todo-server
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
main: ./cmd/mcp-todo-server
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^ci:'
- Merge pull request
- Merge branch
# Homebrew
brews:
- tap:
owner: mutker
name: homebrew-tap
token: "{{ .Env.GITHUB_TOKEN }}"
folder: Formula
homepage: https://codeberg.org/mutker/mcp-todo-server
description: MCP server for managing TODO.md and CHANGELOG.md files
license: MIT
test: |
system "#{bin}/mcp-todo-server --version"
install: |
bin.install "mcp-todo-server"
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# mcp-todo-server
Model Context Protocol (MCP) server for managing TODO.md and CHANGELOG.md files.
## Features
- Precise, line-based editing and reading of file contents.
- Efficient partial file access using line ranges, for efficient LLM tool usage.
- Retrieve specific file content by specifying line ranges.
- Fetch multiple line ranges from multiple files in a single request.
- Apply line-based patches, correctly adjusting for line number changes.
- Supports a wide range of character encodings (utf-8, shift_jis, latin1, etc.).
- Perform atomic operations across multiple files.
- Robust error handling using custom error types.
- Adheres to Semantic Versioning and Keep a Changelog conventions.
## Requirements
- Go v1.23+
- Linux, macOS, or Windows
- File system permissions for read/write operations
## Installation
```bash
go install codeberg.org/mutker/mcp-todo-server/cmd/mcp-todo-server@latest
```
## Usage examples:
- Ask "What are my current tasks for version 0.2.0?"
- Say "Add a new task to implement OAuth authentication for version 0.2.0"
- Request "Generate a changelog entry for version 0.1.0 based on completed tasks"
- Say "Import my existing TODO.md file from /path/to/my/TODO.md"
The server intelligently handles task parsing, version management, and provides rich semantic understanding of tasks and changelog entries.
## Available MCP Tools
### TODO.md Operations
- `get-todo-tasks` - Get all tasks from TODO.md
- `get-todo-tasks-by-version` - Get tasks for a specific version
- `add-todo-task` - Add a new task for a specific version
- `update-todo-task` - Update an existing task
- `add-todo-version` - Add a new version section
- `import-todo` - Import and format an existing TODO.md
### CHANGELOG.md Operations
- `get-changelog` - Get all changelog entries
- `get-changelog-by-version` - Get changelog entries for a specific version
- `add-changelog-entry` - Add a new changelog version entry
- `update-changelog-entry` - Update an existing changelog entry
- `import-changelog` - Import and format an existing CHANGELOG.md
- `generate-changelog-from-todo` - Generate a new CHANGELOG.md entry based on completed tasks in TODO.md
## Thanks
- [tumf/mcp-text-editor](https://github.com/tumf/mcp-text-editor) for the inspiration.
## License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full license text.
```
--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"encoding/json"
"github.com/mark3labs/mcp-go/mcp"
)
// Helper function to create a tool result with JSON content
func newToolResultJSON(v any) (*mcp.CallToolResult, error) {
jsonBytes, err := json.Marshal(v)
if err != nil {
return nil, err
}
return mcp.NewToolResultText(string(jsonBytes)), nil
}
// convertParams is a utility function that converts parameters from JSON
// into a structured type. This is useful for processing tool parameters.
func convertParams(params interface{}, dest interface{}) error {
if params == nil {
return nil
}
// Convert to JSON and unmarshal into the destination struct
paramsJSON, err := json.Marshal(params)
if err != nil {
return err
}
return json.Unmarshal(paramsJSON, dest)
}
```
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
```go
package config
import (
"os"
)
// Config holds the application configuration
type Config struct {
Files FilesConfig
}
// FilesConfig holds configuration related to file operations
type FilesConfig struct {
DefaultEncoding string
AutoDetection bool
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
// Set defaults
cfg := &Config{
Files: FilesConfig{
DefaultEncoding: "utf-8",
AutoDetection: true,
},
}
// Override with environment variables
if encoding := os.Getenv("MCP_DEFAULT_ENCODING"); encoding != "" {
cfg.Files.DefaultEncoding = encoding
}
if autoDetect := os.Getenv("MCP_AUTO_DETECT_ENCODING"); autoDetect != "" {
cfg.Files.AutoDetection = autoDetect == "1" || autoDetect == "true" || autoDetect == "yes"
}
return cfg, nil
}
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
# Changelog
## [0.3.0] - 2025-03-05
### Added
- Support for mcp-go library integration
- Complete refactoring to use mcp-go for protocol handling
- Improved type safety with mcp-go parameter handling
- Enhanced character encoding support
- Automatic encoding detection for files
- Support for additional character encodings (Chinese, Korean, and more)
- Format conversion utilities for seamless encoding management
### Changed
- Simplified server implementation by leveraging mcp-go functionality
- Enhanced tool parameter definitions with proper schema support
- Improved error handling with consistent error responses
- Updated tool implementations to maintain backward compatibility
### Removed
- Custom MCP protocol implementation in favor of the standard mcp-go library
## [0.2.0] - 2025-03-04
### Added
- Model Context Protocol (MCP) server implementation
- JSON-RPC communication layer
- MCP protocol initialization and capability negotiation
- Tool registration and execution system
- Stdin/stdout transport support
- MCP tools for TODO.md
- Get all tasks
- Get tasks for a specific version
- Add a new task
- Update an existing task
- Add a new version section
- Import and format an existing TODO.md
- MCP tools for CHANGELOG.md
- Get all changelog items
- Get changelog items for a specific version
- Add a new changelog version entry
- Update existing changelog entries
- Import and format an existing CHANGELOG.md
- Generate a new CHANGELOG.md based on completed tasks in TODO.md
### Changed
- Updated README with MCP server documentation
- Improved error handling and validation for tools
## [0.1.0] - 2025-03-03
### Added
- Initial implementation of MCP Todo Server
- Server framework with error handling and middleware
- File operations with line-based reading and editing
- Hash-based validation for concurrent editing
- TODO.md management operations
- Parse TODO format
- Read and update tasks
- Add tasks and versions
- CHANGELOG.md management operations
- Parse CHANGELOG format
- Read and update entries
- Add new entries
- Multi-file atomic operations
- Comprehensive encoding support (utf-8, shift_jis, latin1, etc.)
- Goreleaser configuration for multi-platform builds
```
--------------------------------------------------------------------------------
/cmd/mcp-todo-server/main.go:
--------------------------------------------------------------------------------
```go
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/mutker/mcp-todo-server/internal/config"
"codeberg.org/mutker/mcp-todo-server/internal/mcp"
"github.com/mark3labs/mcp-go/server"
)
var (
version = "0.3.0"
verboseFlag = flag.Bool("verbose", false, "Enable verbose logging")
versionFlag = flag.Bool("version", false, "Show version information")
)
func main() {
// Define command-line flags
flag.Parse()
// Show version and exit if requested
if *versionFlag {
fmt.Printf("mcp-todo-server version %s\n", version)
os.Exit(0)
}
// Initialize logger to write to stderr instead of stdout
// This is necessary because stdout is reserved for MCP JSON-RPC communication
logLevel := slog.LevelInfo
if *verboseFlag {
logLevel = slog.LevelDebug
}
// Create a custom handler that formats logs in a more human-readable format
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Format timestamp to be more human readable
if a.Key == "time" {
if t, ok := a.Value.Any().(time.Time); ok {
return slog.Attr{
Key: "time",
Value: slog.StringValue(t.Format("2006-01-02 15:04:05")),
}
}
}
return a
},
})
logger := slog.New(handler)
slog.SetDefault(logger)
// Load configuration
cfg, err := config.Load()
if err != nil {
logger.Error("Failed to load configuration", "error", err)
os.Exit(1)
}
// Print version information
logger.Info(fmt.Sprintf("Starting MCP Todo Server v%s", version))
// Create context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create a channel to listen for OS signals
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Create and start the MCP server
mcpServer := server.NewMCPServer(
"mcp-todo-server",
version,
server.WithResourceCapabilities(false, false), // Disable resources for now
server.WithLogging(),
)
// Register tools
mcp.RegisterTools(ctx, mcpServer, cfg)
// Start the MCP server in a goroutine
go func() {
logger.Info("MCP server ready and listening on stdin/stdout")
if err := server.ServeStdio(mcpServer); err != nil {
logger.Error(fmt.Sprintf("MCP server error: %v", err))
os.Exit(1)
}
}()
// Wait for signal
<-quit
logger.Info("Received shutdown signal, closing server...")
// Cancel the context to initiate shutdown
cancel()
// Give it a moment to clean up
// time.Sleep(500 * time.Millisecond) // No need with mcp-go
logger.Info("MCP Todo Server has shut down gracefully")
}
```
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
```markdown
# TODO
## v0.1.0
- [x] Set up project structure
- [x] Create Go module
- [x] Set up directory structure (cmd, internal, pkg)
- [x] Add gitignore file
- [x] Implement file operations
- [x] Design file service interfaces
- [x] Implement line-based file reading with range support
- [x] Implement line-based editing operations
- [x] Add hash-based validation for concurrent editing
- [x] Implement TODO.md operations
- [x] Parse TODO.md format
- [x] Implement operations for reading TODO items
- [x] Implement operations for updating task status
- [x] Implement operations for adding new tasks and versions
- [x] Implement CHANGELOG.md operations
- [x] Parse CHANGELOG.md format
- [x] Implement operations for reading changelog entries
- [x] Implement operations for adding new entries
- [x] Implement operations for updating existing entries
- [x] Add multi-file atomic operations
- [x] Implement transaction-like behavior for multi-file edits
- [x] Add rollback capability for failed operations
- [x] Add encoding support
- [x] Implement detection and handling of various encodings
- [x] Add support for utf-8, shift_jis, latin1
- [x] Set up goreleaser configuration
- [x] Create .goreleaser.yml
- [x] Configure build settings for different platforms
- [x] Set up release workflow
## v0.2.0
- [x] Implement MCP server
- [x] Create JSON-RPC communication layer
- [x] Implement MCP protocol initialization
- [x] Add tool registration and execution
- [x] Support stdin/stdout transport
- [x] Implement MCP tools for TODO.md
- [x] Get all tasks
- [x] Get tasks for a specific version
- [x] Add a new task
- [x] Add a new task for a specific version
- [x] Update an existing task
- [x] Add a new version section
- [x] Import and format an existing TODO.md
- [x] Implement MCP tools for CHANGELOG.md
- [x] Get all changelog items
- [x] Get changelog items for a specific version
- [x] Add a new changelog version entry
- [x] Add a new changelog entry for a specific version
- [x] Update a existing changelog entry
- [x] Import and format an existing CHANGELOG.md
- [x] Generate a new CHANGELOG.md based on completed tasks in TODO.md
## v0.3.0
- [x] Refactor to use `mcp-go` library
- [x] Replace custom MCP implementation with `mcp-go`
- [x] Adapt tool handlers to `mcp-go` API
- [x] Ensure all TODO.md operations are supported
- [x] Ensure all CHANGELOG.md operations are supported
- [x] Clean up legacy MCP server code
- [x] Remove obsolete code replaced by mcp-go
- [x] Refactor server initialization
- [x] Update error handling for MCP protocol
- [x] Support for additional character encodings and format detection
- [x] Add automatic encoding detection
- [x] Support for more exotic character encodings
- [x] Format conversion utilities
```
--------------------------------------------------------------------------------
/internal/services/changelog/changelog.go:
--------------------------------------------------------------------------------
```go
package changelog
import (
"errors"
"fmt"
"regexp"
"strings"
"codeberg.org/mutker/mcp-todo-server/internal/config"
"codeberg.org/mutker/mcp-todo-server/internal/services/file"
)
const (
// Default CHANGELOG file name
changelogFileName = "CHANGELOG.md"
)
var (
// ErrVersionNotFound is returned when a version is not found
ErrVersionNotFound = errors.New("version not found")
// ErrVersionExists is returned when a version already exists
ErrVersionExists = errors.New("version already exists")
)
// versionRegex matches a version header (e.g., "## [1.0.0] - 2023-01-01")
var versionRegex = regexp.MustCompile(`^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$`)
// sectionRegex matches a section header (e.g., "### Added" or "### Fixed")
var sectionRegex = regexp.MustCompile(`^### ([A-Za-z]+)$`)
// listItemRegex matches a list item (e.g., "- List item")
var listItemRegex = regexp.MustCompile(`^(\s*)- (.+)$`)
// ChangelogContent represents the content of a changelog entry
type ChangelogContent struct {
Added []string `json:"added,omitempty"`
Changed []string `json:"changed,omitempty"`
Deprecated []string `json:"deprecated,omitempty"`
Removed []string `json:"removed,omitempty"`
Fixed []string `json:"fixed,omitempty"`
Security []string `json:"security,omitempty"`
}
// VersionEntry represents a version entry in the changelog
type VersionEntry struct {
Version string `json:"version"`
Date string `json:"date"`
Content *ChangelogContent `json:"content"`
}
// Changelog represents the entire CHANGELOG.md file
type Changelog struct {
Versions []*VersionEntry `json:"versions"`
}
// Service provides changelog operations
type Service struct {
config *config.Config
fileService *file.Service
}
// NewService creates a new changelog service
func NewService(cfg *config.Config) *Service {
fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
return &Service{
config: cfg,
fileService: fileService,
}
}
// GetAll retrieves the entire changelog
func (s *Service) GetAll() (*Changelog, error) {
lines, _, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
if err != nil {
if errors.Is(err, file.ErrFileNotFound) {
// Return empty changelog if file doesn't exist
return &Changelog{Versions: []*VersionEntry{}}, nil
}
return nil, fmt.Errorf("failed to read CHANGELOG file: %w", err)
}
return s.parseChangelogFile(lines)
}
// GetByVersion retrieves a specific version entry
func (s *Service) GetByVersion(version string) (*VersionEntry, error) {
changelog, err := s.GetAll()
if err != nil {
return nil, err
}
// Find the requested version
for _, v := range changelog.Versions {
if v.Version == version {
return v, nil
}
}
return nil, nil
}
// AddEntry adds a new changelog entry
func (s *Service) AddEntry(version string, date string, content *ChangelogContent) error {
lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
if err != nil && !errors.Is(err, file.ErrFileNotFound) {
return fmt.Errorf("failed to read CHANGELOG file: %w", err)
}
// If file doesn't exist, create it
if errors.Is(err, file.ErrFileNotFound) {
newLines := []string{
"# Changelog",
"",
}
err = s.fileService.InsertLines(changelogFileName, "", s.config.Files.DefaultEncoding, 1, newLines)
if err != nil {
return fmt.Errorf("failed to create CHANGELOG file: %w", err)
}
// Re-read the file
lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
if err != nil {
return fmt.Errorf("failed to read CHANGELOG file: %w", err)
}
}
// Parse the file to check if version already exists
changelog, err := s.parseChangelogFile(lines)
if err != nil {
return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
}
// Check if version already exists
for _, v := range changelog.Versions {
if v.Version == version {
return ErrVersionExists
}
}
// Generate the entry
entryLines := []string{}
// Add version header
entryLines = append(entryLines, fmt.Sprintf("## [%s] - %s", version, date))
entryLines = append(entryLines, "")
// Add sections
if len(content.Added) > 0 {
entryLines = append(entryLines, "### Added")
entryLines = append(entryLines, "")
for _, item := range content.Added {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
if len(content.Changed) > 0 {
entryLines = append(entryLines, "### Changed")
entryLines = append(entryLines, "")
for _, item := range content.Changed {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
if len(content.Deprecated) > 0 {
entryLines = append(entryLines, "### Deprecated")
entryLines = append(entryLines, "")
for _, item := range content.Deprecated {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
if len(content.Removed) > 0 {
entryLines = append(entryLines, "### Removed")
entryLines = append(entryLines, "")
for _, item := range content.Removed {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
if len(content.Fixed) > 0 {
entryLines = append(entryLines, "### Fixed")
entryLines = append(entryLines, "")
for _, item := range content.Fixed {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
if len(content.Security) > 0 {
entryLines = append(entryLines, "### Security")
entryLines = append(entryLines, "")
for _, item := range content.Security {
entryLines = append(entryLines, fmt.Sprintf("- %s", item))
}
entryLines = append(entryLines, "")
}
// Find position to insert the new entry
// We'll add it after the title and before any other version
position := 2 // After "# Changelog" and empty line
for i, line := range lines {
if versionRegex.MatchString(line) {
position = i
break
}
}
// Insert the entry
err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, position, entryLines)
if err != nil {
return fmt.Errorf("failed to add entry: %w", err)
}
return nil
}
// UpdateEntry updates an existing changelog entry
func (s *Service) UpdateEntry(version string, date string, content *ChangelogContent) error {
lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
if err != nil {
return fmt.Errorf("failed to read CHANGELOG file: %w", err)
}
// Parse the file to locate the version
changelog, err := s.parseChangelogFile(lines)
if err != nil {
return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
}
// Find the version entry
var targetVersion *VersionEntry
for _, v := range changelog.Versions {
if v.Version == version {
targetVersion = v
break
}
}
if targetVersion == nil {
return ErrVersionNotFound
}
// Find the start and end line numbers for this version
startLine := 0
endLine := len(lines)
for i, line := range lines {
if versionRegex.MatchString(line) {
versionMatch := versionRegex.FindStringSubmatch(line)
if versionMatch[1] == version {
startLine = i
// Find the end line (next version or end of file)
for j := i + 1; j < len(lines); j++ {
if versionRegex.MatchString(lines[j]) {
endLine = j
break
}
}
break
}
}
}
// Generate new version entry
newLines := []string{}
// Update version header if date is provided
if date != "" {
newLines = append(newLines, fmt.Sprintf("## [%s] - %s", version, date))
} else {
newLines = append(newLines, lines[startLine]) // Keep original date
}
newLines = append(newLines, "")
// Add sections
// We'll generate completely new content rather than trying to modify existing content
if content.Added != nil && len(content.Added) > 0 {
newLines = append(newLines, "### Added")
newLines = append(newLines, "")
for _, item := range content.Added {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
if content.Changed != nil && len(content.Changed) > 0 {
newLines = append(newLines, "### Changed")
newLines = append(newLines, "")
for _, item := range content.Changed {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
if content.Deprecated != nil && len(content.Deprecated) > 0 {
newLines = append(newLines, "### Deprecated")
newLines = append(newLines, "")
for _, item := range content.Deprecated {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
if content.Removed != nil && len(content.Removed) > 0 {
newLines = append(newLines, "### Removed")
newLines = append(newLines, "")
for _, item := range content.Removed {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
if content.Fixed != nil && len(content.Fixed) > 0 {
newLines = append(newLines, "### Fixed")
newLines = append(newLines, "")
for _, item := range content.Fixed {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
if content.Security != nil && len(content.Security) > 0 {
newLines = append(newLines, "### Security")
newLines = append(newLines, "")
for _, item := range content.Security {
newLines = append(newLines, fmt.Sprintf("- %s", item))
}
newLines = append(newLines, "")
}
// Delete the old version entry
var lineNumbers []int
for i := startLine; i < endLine; i++ {
lineNumbers = append(lineNumbers, i+1)
}
err = s.fileService.DeleteLines(changelogFileName, hash, s.config.Files.DefaultEncoding, lineNumbers)
if err != nil {
return fmt.Errorf("failed to delete old entry: %w", err)
}
// Re-read the file to get the updated hash
lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
if err != nil {
return fmt.Errorf("failed to read CHANGELOG file: %w", err)
}
// Insert the new version entry
err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, startLine+1, newLines)
if err != nil {
return fmt.Errorf("failed to insert new entry: %w", err)
}
return nil
}
// parseChangelogFile parses a CHANGELOG.md file into a structured representation
func (s *Service) parseChangelogFile(lines []string) (*Changelog, error) {
changelog := &Changelog{
Versions: []*VersionEntry{},
}
var currentVersion *VersionEntry
var currentSection string
for _, line := range lines {
// Skip empty lines and title
if line == "" || line == "# Changelog" {
continue
}
// Check if it's a version header
if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
version := versionMatch[1]
date := versionMatch[2]
currentVersion = &VersionEntry{
Version: version,
Date: date,
Content: &ChangelogContent{},
}
changelog.Versions = append(changelog.Versions, currentVersion)
currentSection = ""
continue
}
// If no current version, skip
if currentVersion == nil {
continue
}
// Check if it's a section header
if sectionMatch := sectionRegex.FindStringSubmatch(line); sectionMatch != nil {
currentSection = strings.ToLower(sectionMatch[1])
continue
}
// Check if it's a list item
if listItemMatch := listItemRegex.FindStringSubmatch(line); listItemMatch != nil {
item := listItemMatch[2]
// Add to the appropriate section
switch currentSection {
case "added":
currentVersion.Content.Added = append(currentVersion.Content.Added, item)
case "changed":
currentVersion.Content.Changed = append(currentVersion.Content.Changed, item)
case "deprecated":
currentVersion.Content.Deprecated = append(currentVersion.Content.Deprecated, item)
case "removed":
currentVersion.Content.Removed = append(currentVersion.Content.Removed, item)
case "fixed":
currentVersion.Content.Fixed = append(currentVersion.Content.Fixed, item)
case "security":
currentVersion.Content.Security = append(currentVersion.Content.Security, item)
}
}
}
return changelog, nil
}
```
--------------------------------------------------------------------------------
/internal/services/todo/todo.go:
--------------------------------------------------------------------------------
```go
package todo
import (
"errors"
"fmt"
"regexp"
"strings"
"codeberg.org/mutker/mcp-todo-server/internal/config"
"codeberg.org/mutker/mcp-todo-server/internal/services/file"
)
const (
// Default TODO file name
todoFileName = "TODO.md"
)
var (
// ErrTaskNotFound is returned when a task is not found
ErrTaskNotFound = errors.New("task not found")
// ErrVersionNotFound is returned when a version is not found
ErrVersionNotFound = errors.New("version not found")
// ErrVersionExists is returned when a version already exists
ErrVersionExists = errors.New("version already exists")
)
// versionRegex matches a version header (e.g., "## v1.0.0")
var versionRegex = regexp.MustCompile(`^## v(\d+\.\d+\.\d+)$`)
// taskRegex matches a task line (e.g., "- [ ] Task description" or "- [x] Completed task")
var taskRegex = regexp.MustCompile(`^(\s*)- \[([ xX])\] (.+)$`)
// Task represents a todo task
type Task struct {
ID string `json:"id"`
Description string `json:"description"`
Completed bool `json:"completed"`
Version string `json:"version"`
LineNumber int `json:"line_number"`
Indent int `json:"indent"`
SubTasks []*Task `json:"subtasks,omitempty"`
}
// VersionTasks represents tasks grouped by version
type VersionTasks struct {
Version string `json:"version"`
Tasks []*Task `json:"tasks"`
}
// TodoList represents the entire TODO.md file
type TodoList struct {
Versions []*VersionTasks `json:"versions"`
}
// Service provides todo operations
type Service struct {
config *config.Config
fileService *file.Service
}
// NewService creates a new todo service
func NewService(cfg *config.Config) *Service {
fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
return &Service{
config: cfg,
fileService: fileService,
}
}
// GetAll retrieves all tasks from TODO.md
func (s *Service) GetAll() (*TodoList, error) {
lines, _, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil {
if errors.Is(err, file.ErrFileNotFound) {
// Return empty todo list if file doesn't exist
return &TodoList{Versions: []*VersionTasks{}}, nil
}
return nil, fmt.Errorf("failed to read TODO file: %w", err)
}
return s.parseTodoFile(lines)
}
// GetByVersion retrieves tasks for a specific version
func (s *Service) GetByVersion(version string) (*VersionTasks, error) {
todoList, err := s.GetAll()
if err != nil {
return nil, err
}
// Find the requested version
for _, v := range todoList.Versions {
if v.Version == version {
return v, nil
}
}
return nil, nil
}
// AddTask adds a new task to TODO.md
func (s *Service) AddTask(version string, description string, parentID string) (string, error) {
lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil && !errors.Is(err, file.ErrFileNotFound) {
return "", fmt.Errorf("failed to read TODO file: %w", err)
}
// If file doesn't exist, create it with the version
if errors.Is(err, file.ErrFileNotFound) {
err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
"# TODO",
"",
fmt.Sprintf("## v%s", version),
})
if err != nil {
return "", fmt.Errorf("failed to create TODO file: %w", err)
}
// Re-read the file
lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil {
return "", fmt.Errorf("failed to read TODO file: %w", err)
}
}
// Parse the file to locate the version and determine insert position
todoList, err := s.parseTodoFile(lines)
if err != nil {
return "", fmt.Errorf("failed to parse TODO file: %w", err)
}
// Check if version exists
versionExists := false
var versionTasksObj *VersionTasks
for _, v := range todoList.Versions {
if v.Version == version {
versionExists = true
versionTasksObj = v
break
}
}
// If version doesn't exist, add it
if !versionExists {
// Find the position to insert the new version
// We'll add it after the last version or at the end if no versions exist
position := len(lines) + 1
for i := len(lines) - 1; i >= 0; i-- {
if versionRegex.MatchString(lines[i]) {
position = i + 1
break
}
}
// Insert the version header
err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
"",
fmt.Sprintf("## v%s", version),
})
if err != nil {
return "", fmt.Errorf("failed to add version: %w", err)
}
// Re-read the file
lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil {
return "", fmt.Errorf("failed to read TODO file: %w", err)
}
// Re-parse the file
todoList, err = s.parseTodoFile(lines)
if err != nil {
return "", fmt.Errorf("failed to parse TODO file: %w", err)
}
// Find the version again
for _, v := range todoList.Versions {
if v.Version == version {
versionTasksObj = v
break
}
}
}
// Determine insertion position and indentation
var insertPosition int
var indentation string
if parentID == "" {
// If no parent task, add at the end of the version section
if len(versionTasksObj.Tasks) == 0 {
// If no tasks in this version, add right after the version header
for i, line := range lines {
if line == fmt.Sprintf("## v%s", version) {
insertPosition = i + 1
break
}
}
} else {
// Find the last task in this version
lastTask := versionTasksObj.Tasks[len(versionTasksObj.Tasks)-1]
// Handle case where last task has subtasks
// We need to find the last subtask recursively
var findLastLine func(*Task) int
findLastLine = func(t *Task) int {
if len(t.SubTasks) == 0 {
return t.LineNumber
}
return findLastLine(t.SubTasks[len(t.SubTasks)-1])
}
lastLine := findLastLine(lastTask)
insertPosition = lastLine
}
} else {
// Find the parent task
var parentTask *Task
var findTask func([]*Task) *Task
findTask = func(tasks []*Task) *Task {
for _, t := range tasks {
if t.ID == parentID {
return t
}
if result := findTask(t.SubTasks); result != nil {
return result
}
}
return nil
}
for _, v := range todoList.Versions {
if parent := findTask(v.Tasks); parent != nil {
parentTask = parent
break
}
}
if parentTask == nil {
return "", ErrTaskNotFound
}
// Add as a subtask of the parent
insertPosition = parentTask.LineNumber
indentation = strings.Repeat(" ", parentTask.Indent+2)
}
// Add the task
newTaskLine := fmt.Sprintf("%s- [ ] %s", indentation, description)
err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, insertPosition+1, []string{newTaskLine})
if err != nil {
return "", fmt.Errorf("failed to add task: %w", err)
}
// Re-read and parse to get the ID of the new task
lines, _, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil {
return "", fmt.Errorf("failed to read TODO file: %w", err)
}
todoList, err = s.parseTodoFile(lines)
if err != nil {
return "", fmt.Errorf("failed to parse TODO file: %w", err)
}
// Find the task we just added to get its ID
var newTaskID string
for _, v := range todoList.Versions {
if v.Version == version {
for _, t := range v.Tasks {
if t.LineNumber == insertPosition+1 && t.Description == description {
newTaskID = t.ID
return newTaskID, nil
}
// Also check subtasks
var findNewTask func([]*Task) string
findNewTask = func(tasks []*Task) string {
for _, st := range tasks {
if st.LineNumber == insertPosition+1 && st.Description == description {
return st.ID
}
if id := findNewTask(st.SubTasks); id != "" {
return id
}
}
return ""
}
if id := findNewTask(t.SubTasks); id != "" {
return id, nil
}
}
}
}
// If we couldn't find the new task (shouldn't happen), generate a best-guess ID
return generateTaskID(version, description), nil
}
// UpdateTask updates an existing task
func (s *Service) UpdateTask(taskID string, completed *bool, description *string) error {
lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil {
return fmt.Errorf("failed to read TODO file: %w", err)
}
todoList, err := s.parseTodoFile(lines)
if err != nil {
return fmt.Errorf("failed to parse TODO file: %w", err)
}
// Find the task
var taskToUpdate *Task
var findTask func([]*Task) *Task
findTask = func(tasks []*Task) *Task {
for _, t := range tasks {
if t.ID == taskID {
return t
}
if result := findTask(t.SubTasks); result != nil {
return result
}
}
return nil
}
for _, v := range todoList.Versions {
if task := findTask(v.Tasks); task != nil {
taskToUpdate = task
break
}
}
if taskToUpdate == nil {
return ErrTaskNotFound
}
// Parse the line to extract indentation and create the new line
indentMatch := strings.Repeat(" ", taskToUpdate.Indent)
// Determine completion status
completionStatus := " "
if completed != nil {
if *completed {
completionStatus = "x"
}
} else if taskToUpdate.Completed {
completionStatus = "x"
}
// Determine description
desc := taskToUpdate.Description
if description != nil {
desc = *description
}
// Create the new line
newLine := fmt.Sprintf("%s- [%s] %s", indentMatch, completionStatus, desc)
// Update the line
lineEdits := map[int]string{
taskToUpdate.LineNumber: newLine,
}
err = s.fileService.EditLines(todoFileName, hash, s.config.Files.DefaultEncoding, lineEdits)
if err != nil {
return fmt.Errorf("failed to update task: %w", err)
}
return nil
}
// AddVersion adds a new version section to TODO.md
func (s *Service) AddVersion(version string) error {
lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
if err != nil && !errors.Is(err, file.ErrFileNotFound) {
return fmt.Errorf("failed to read TODO file: %w", err)
}
// If file doesn't exist, create it
if errors.Is(err, file.ErrFileNotFound) {
err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
"# TODO",
"",
fmt.Sprintf("## v%s", version),
})
if err != nil {
return fmt.Errorf("failed to create TODO file: %w", err)
}
return nil
}
// Check if version already exists
todoList, err := s.parseTodoFile(lines)
if err != nil {
return fmt.Errorf("failed to parse TODO file: %w", err)
}
for _, v := range todoList.Versions {
if v.Version == version {
return ErrVersionExists
}
}
// Find the position to insert the new version
// We'll add it before the first greater version or at the end if no greater version exists
position := len(lines) + 1
// Make sure todoList.Versions is sorted by version
// For simplicity, we'll just add it at the end for now
// In a real implementation, we would sort the versions semantically
// Insert the version
err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
"",
fmt.Sprintf("## v%s", version),
})
if err != nil {
return fmt.Errorf("failed to add version: %w", err)
}
return nil
}
// parseTodoFile parses a TODO.md file into a structured representation
func (s *Service) parseTodoFile(lines []string) (*TodoList, error) {
todoList := &TodoList{
Versions: []*VersionTasks{},
}
var currentVersion *VersionTasks
var taskStack [][]*Task
for i, line := range lines {
lineNum := i + 1
// Skip empty lines and the title
if line == "" || line == "# TODO" {
continue
}
// Check if it's a version header
if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
version := versionMatch[1]
currentVersion = &VersionTasks{
Version: version,
Tasks: []*Task{},
}
todoList.Versions = append(todoList.Versions, currentVersion)
taskStack = nil
continue
}
// If no current version, skip
if currentVersion == nil {
continue
}
// Check if it's a task
if taskMatch := taskRegex.FindStringSubmatch(line); taskMatch != nil {
indentation := taskMatch[1]
completed := taskMatch[2] == "x" || taskMatch[2] == "X"
description := taskMatch[3]
indent := len(indentation)
task := &Task{
ID: generateTaskID(currentVersion.Version, description),
Description: description,
Completed: completed,
Version: currentVersion.Version,
LineNumber: lineNum,
Indent: indent,
SubTasks: []*Task{},
}
// Handle task hierarchy based on indentation
if indent == 0 {
// Top-level task
currentVersion.Tasks = append(currentVersion.Tasks, task)
taskStack = [][]*Task{{task}}
} else {
// Find the parent task based on indentation
level := indent / 2
// Ensure the stack has enough levels
for len(taskStack) <= level {
taskStack = append(taskStack, []*Task{})
}
// Add the task to its level
taskStack[level] = append(taskStack[level], task)
// Add as a subtask to the parent
if level > 0 {
parentLevel := level - 1
parentTasks := taskStack[parentLevel]
if len(parentTasks) > 0 {
parent := parentTasks[len(parentTasks)-1]
parent.SubTasks = append(parent.SubTasks, task)
}
}
}
}
}
return todoList, nil
}
// generateTaskID generates a unique ID for a task
func generateTaskID(version string, description string) string {
// Generate a simple ID based on the version and description
// In a real implementation, you might want to use something more robust
cleanDesc := strings.ToLower(description)
cleanDesc = strings.Replace(cleanDesc, " ", "-", -1)
cleanDesc = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(cleanDesc, "")
if len(cleanDesc) > 20 {
cleanDesc = cleanDesc[:20]
}
return fmt.Sprintf("%s-%s", version, cleanDesc)
}
```
--------------------------------------------------------------------------------
/internal/services/file/file.go:
--------------------------------------------------------------------------------
```go
package file
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
)
var (
// ErrFileNotFound is returned when a file does not exist
ErrFileNotFound = errors.New("file not found")
// ErrContentChanged is returned when the file content has changed since it was last read
ErrContentChanged = errors.New("file content has changed")
// ErrInvalidRange is returned when an invalid range is specified
ErrInvalidRange = errors.New("invalid range specified")
// ErrInvalidEncoding is returned when an unsupported encoding is specified
ErrInvalidEncoding = errors.New("invalid encoding specified")
)
// LineRange represents a range of lines in a file
type LineRange struct {
Start int
End int
}
// Service provides file operation methods
type Service struct {
defaultEncoding string
autoDetectEncoding bool
}
// NewService creates a new file service
func NewService(defaultEncoding string, autoDetect bool) *Service {
if defaultEncoding == "" {
defaultEncoding = "utf-8"
}
return &Service{
defaultEncoding: defaultEncoding,
autoDetectEncoding: autoDetect,
}
}
// ReadLines reads lines from a file with optional range specification
func (s *Service) ReadLines(filePath string, encName string, ranges ...LineRange) ([]string, string, error) {
// Validate file existence
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, "", ErrFileNotFound
}
// Handle automatic encoding detection
var enc encoding.Encoding
var err error
if strings.ToLower(encName) == "auto" {
// Attempt to detect the encoding
enc, encName, err = s.DetectEncoding(filePath)
if err != nil {
// Fall back to default encoding on detection failure
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return nil, "", err
}
}
} else {
// Get the encoding specified by the user
enc, err = s.getEncoding(encName)
if err != nil {
return nil, "", err
}
}
// Open the file
file, err := os.Open(filePath)
if err != nil {
return nil, "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Read the entire file and calculate its hash
content, err := io.ReadAll(file)
if err != nil {
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
// Calculate hash of the content
hash := sha256.Sum256(content)
hashStr := hex.EncodeToString(hash[:])
// Decode content if needed
var decodedContent []byte
if enc != nil {
decoder := enc.NewDecoder()
decodedContent, err = decoder.Bytes(content)
if err != nil {
return nil, "", fmt.Errorf("failed to decode content: %w", err)
}
} else {
decodedContent = content
}
// Split into lines
lines := strings.Split(string(decodedContent), "\n")
// If no ranges provided, return all lines
if len(ranges) == 0 {
return lines, hashStr, nil
}
// Process each range
var result []string
for _, r := range ranges {
// Validate range
if r.Start < 0 || r.End > len(lines) || r.Start > r.End {
return nil, "", ErrInvalidRange
}
// Add lines from this range
result = append(result, lines[r.Start-1:r.End]...)
}
return result, hashStr, nil
}
// EditLines edits lines in a file with hash validation
func (s *Service) EditLines(filePath string, oldHash string, encName string, lineEdits map[int]string) error {
// Validate file existence
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return ErrFileNotFound
}
// Handle automatic encoding detection
var enc encoding.Encoding
var err error
if strings.ToLower(encName) == "auto" {
// Attempt to detect the encoding
enc, encName, err = s.DetectEncoding(filePath)
if err != nil {
// Fall back to default encoding on detection failure
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// Get the encoding specified by the user
enc, err = s.getEncoding(encName)
if err != nil {
return err
}
}
// Read current file content
file, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Verify hash if provided
if oldHash != "" {
hash := sha256.Sum256(file)
currentHash := hex.EncodeToString(hash[:])
if currentHash != oldHash {
return ErrContentChanged
}
}
// Decode content if needed
var decodedContent []byte
if enc != nil {
decoder := enc.NewDecoder()
decodedContent, err = decoder.Bytes(file)
if err != nil {
return fmt.Errorf("failed to decode content: %w", err)
}
} else {
decodedContent = file
}
// Split into lines
lines := strings.Split(string(decodedContent), "\n")
// Apply the edits
for lineNum, newContent := range lineEdits {
if lineNum < 1 || lineNum > len(lines) {
return ErrInvalidRange
}
lines[lineNum-1] = newContent
}
// Combine back into a single string
newContent := strings.Join(lines, "\n")
// Encode the content if needed
var finalContent []byte
if enc != nil {
encoder := enc.NewEncoder()
finalContent, err = encoder.Bytes([]byte(newContent))
if err != nil {
return fmt.Errorf("failed to encode content: %w", err)
}
} else {
finalContent = []byte(newContent)
}
// Create the directory if it doesn't exist
dir := filepath.Dir(filePath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
// Write the file
err = os.WriteFile(filePath, finalContent, 0644)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// AppendLines appends lines to a file
func (s *Service) AppendLines(filePath string, encName string, lines []string) error {
// Check if file exists for encoding detection
fileExists := false
if _, err := os.Stat(filePath); err == nil {
fileExists = true
}
// Handle automatic encoding detection or get the specified encoding
var enc encoding.Encoding
var err error
if strings.ToLower(encName) == "auto" {
if fileExists {
// Attempt to detect the encoding for existing files
enc, encName, err = s.DetectEncoding(filePath)
if err != nil {
// Fall back to default encoding on detection failure
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// For new files, use the default encoding
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// Get the encoding specified by the user
enc, err = s.getEncoding(encName)
if err != nil {
return err
}
}
// Convert lines to a single string
newContent := strings.Join(lines, "\n")
// Add a newline if the file exists and doesn't end with one
if _, err := os.Stat(filePath); err == nil {
content, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
if len(content) > 0 && content[len(content)-1] != '\n' {
newContent = "\n" + newContent
}
}
// Encode the content if needed
var finalContent []byte
if enc != nil {
encoder := enc.NewEncoder()
finalContent, err = encoder.Bytes([]byte(newContent))
if err != nil {
return fmt.Errorf("failed to encode content: %w", err)
}
} else {
finalContent = []byte(newContent)
}
// Create the directory if it doesn't exist
dir := filepath.Dir(filePath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
// Open file in append mode
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
// Write the content
if _, err := f.Write(finalContent); err != nil {
return fmt.Errorf("failed to append to file: %w", err)
}
return nil
}
// DeleteLines deletes lines from a file with hash validation
func (s *Service) DeleteLines(filePath string, oldHash string, encName string, lineNumbers []int) error {
// Validate file existence
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return ErrFileNotFound
}
// Handle automatic encoding detection
var enc encoding.Encoding
var err error
if strings.ToLower(encName) == "auto" {
// Attempt to detect the encoding
enc, encName, err = s.DetectEncoding(filePath)
if err != nil {
// Fall back to default encoding on detection failure
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// Get the encoding specified by the user
enc, err = s.getEncoding(encName)
if err != nil {
return err
}
}
// Read current file content
file, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Verify hash if provided
if oldHash != "" {
hash := sha256.Sum256(file)
currentHash := hex.EncodeToString(hash[:])
if currentHash != oldHash {
return ErrContentChanged
}
}
// Decode content if needed
var decodedContent []byte
if enc != nil {
decoder := enc.NewDecoder()
decodedContent, err = decoder.Bytes(file)
if err != nil {
return fmt.Errorf("failed to decode content: %w", err)
}
} else {
decodedContent = file
}
// Split into lines
lines := strings.Split(string(decodedContent), "\n")
// Create a map of lines to delete for O(1) lookup
toDelete := make(map[int]bool)
for _, lineNum := range lineNumbers {
if lineNum < 1 || lineNum > len(lines) {
return ErrInvalidRange
}
toDelete[lineNum-1] = true
}
// Create a new slice without the deleted lines
var newLines []string
for i, line := range lines {
if !toDelete[i] {
newLines = append(newLines, line)
}
}
// Combine back into a single string
newContent := strings.Join(newLines, "\n")
// Encode the content if needed
var finalContent []byte
if enc != nil {
encoder := enc.NewEncoder()
finalContent, err = encoder.Bytes([]byte(newContent))
if err != nil {
return fmt.Errorf("failed to encode content: %w", err)
}
} else {
finalContent = []byte(newContent)
}
// Write the file
err = os.WriteFile(filePath, finalContent, 0644)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// InsertLines inserts lines at a specific position with hash validation
func (s *Service) InsertLines(filePath string, oldHash string, encName string, position int, newLines []string) error {
// Validate file existence
isNewFile := false
if _, err := os.Stat(filePath); os.IsNotExist(err) {
isNewFile = true
// If this is a new file and position is not 1, return an error
if position != 1 {
return ErrInvalidRange
}
}
// Handle automatic encoding detection or get the specified encoding
var enc encoding.Encoding
var err error
if strings.ToLower(encName) == "auto" {
if !isNewFile {
// Attempt to detect the encoding for existing files
enc, encName, err = s.DetectEncoding(filePath)
if err != nil {
// Fall back to default encoding on detection failure
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// For new files, use the default encoding
enc, err = s.getEncoding(s.defaultEncoding)
if err != nil {
return err
}
}
} else {
// Get the encoding specified by the user
enc, err = s.getEncoding(encName)
if err != nil {
return err
}
}
var lines []string
if !isNewFile {
// Read current file content
file, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Verify hash if provided
if oldHash != "" {
hash := sha256.Sum256(file)
currentHash := hex.EncodeToString(hash[:])
if currentHash != oldHash {
return ErrContentChanged
}
}
// Decode content if needed
var decodedContent []byte
if enc != nil {
decoder := enc.NewDecoder()
decodedContent, err = decoder.Bytes(file)
if err != nil {
return fmt.Errorf("failed to decode content: %w", err)
}
} else {
decodedContent = file
}
// Split into lines
lines = strings.Split(string(decodedContent), "\n")
// Validate position
if position < 1 || position > len(lines)+1 {
return ErrInvalidRange
}
} else {
lines = []string{}
}
// Insert the new lines
var resultLines []string
if position == 1 {
resultLines = append(newLines, lines...)
} else if position == len(lines)+1 {
resultLines = append(lines, newLines...)
} else {
resultLines = append(resultLines, lines[:position-1]...)
resultLines = append(resultLines, newLines...)
resultLines = append(resultLines, lines[position-1:]...)
}
// Combine back into a single string
newContent := strings.Join(resultLines, "\n")
// Encode the content if needed
var finalContent []byte
if enc != nil {
encoder := enc.NewEncoder()
finalContent, err = encoder.Bytes([]byte(newContent))
if err != nil {
return fmt.Errorf("failed to encode content: %w", err)
}
} else {
finalContent = []byte(newContent)
}
// Create the directory if it doesn't exist
dir := filepath.Dir(filePath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
// Write the file
err = os.WriteFile(filePath, finalContent, 0644)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// DetectEncoding attempts to detect the encoding of a file
func (s *Service) DetectEncoding(filePath string) (encoding.Encoding, string, error) {
// Open the file
file, err := os.Open(filePath)
if err != nil {
return nil, "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Read a small portion of the file for detection
var buf [1024]byte
n, err := file.Read(buf[:])
if err != nil && err != io.EOF {
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
// Reset the file pointer to the beginning
_, err = file.Seek(0, 0)
if err != nil {
return nil, "", fmt.Errorf("failed to reset file position: %w", err)
}
// Detect encoding
e, name, _ := charset.DetermineEncoding(buf[:n], "")
// Normalize name to our standard format
normalizedName := s.normalizeEncodingName(name)
return e, normalizedName, nil
}
// normalizeEncodingName normalizes encoding names to our standard format
func (s *Service) normalizeEncodingName(name string) string {
name = strings.ToLower(name)
switch name {
case "utf-8", "utf8":
return "utf-8"
case "windows-1252", "cp1252":
return "windows-1252"
case "iso-8859-1":
return "latin1"
case "shift_jis", "shift-jis", "shiftjis":
return "shift_jis"
case "gbk", "gb18030", "gb2312":
return "gbk"
case "big5", "big5-hkscs":
return "big5"
case "euc-jp":
return "euc-jp"
case "euc-kr":
return "euc-kr"
default:
return name
}
}
// GetEncoding returns the appropriate encoding based on the provided name
// If name is "auto", it will attempt to detect the encoding
func (s *Service) getEncoding(encName string) (encoding.Encoding, error) {
if encName == "" {
encName = s.defaultEncoding
}
switch strings.ToLower(encName) {
case "utf-8", "utf8":
return nil, nil // No encoding/decoding needed for UTF-8
case "utf-16", "utf16":
return unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), nil
case "utf-16be", "utf16be":
return unicode.UTF16(unicode.BigEndian, unicode.UseBOM), nil
case "utf-16le", "utf16le":
return unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), nil
// Japanese encodings
case "shift_jis", "shift-jis", "shiftjis":
return japanese.ShiftJIS, nil
case "euc-jp":
return japanese.EUCJP, nil
case "iso-2022-jp":
return japanese.ISO2022JP, nil
// Chinese encodings
case "gbk", "gb18030", "gb2312":
return simplifiedchinese.GBK, nil
case "big5", "big5-hkscs":
return traditionalchinese.Big5, nil
// Korean encodings
case "euc-kr":
return korean.EUCKR, nil
// Western encodings
case "iso-8859-1", "latin1":
return charmap.ISO8859_1, nil
case "iso-8859-2", "latin2":
return charmap.ISO8859_2, nil
case "iso-8859-3", "latin3":
return charmap.ISO8859_3, nil
case "iso-8859-4", "latin4":
return charmap.ISO8859_4, nil
case "iso-8859-5":
return charmap.ISO8859_5, nil
case "iso-8859-6":
return charmap.ISO8859_6, nil
case "iso-8859-7":
return charmap.ISO8859_7, nil
case "iso-8859-8":
return charmap.ISO8859_8, nil
case "iso-8859-9", "latin5":
return charmap.ISO8859_9, nil
case "iso-8859-10", "latin6":
return charmap.ISO8859_10, nil
case "iso-8859-13", "latin7":
return charmap.ISO8859_13, nil
case "iso-8859-14", "latin8":
return charmap.ISO8859_14, nil
case "iso-8859-15", "latin9":
return charmap.ISO8859_15, nil
case "iso-8859-16":
return charmap.ISO8859_16, nil
// Windows encodings
case "windows-1250":
return charmap.Windows1250, nil
case "windows-1251":
return charmap.Windows1251, nil
case "windows-1252", "cp1252":
return charmap.Windows1252, nil
case "windows-1253":
return charmap.Windows1253, nil
case "windows-1254":
return charmap.Windows1254, nil
case "windows-1255":
return charmap.Windows1255, nil
case "windows-1256":
return charmap.Windows1256, nil
case "windows-1257":
return charmap.Windows1257, nil
case "windows-1258":
return charmap.Windows1258, nil
default:
return nil, ErrInvalidEncoding
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/tools.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/mutker/mcp-todo-server/internal/config"
"codeberg.org/mutker/mcp-todo-server/internal/services/changelog"
"codeberg.org/mutker/mcp-todo-server/internal/services/todo"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// This function is now defined in server.go
// registerTodoTools registers TODO.md related tools
func RegisterTools(ctx context.Context, s *server.MCPServer, cfg *config.Config) {
todoService := todo.NewService(cfg)
changelogService := changelog.NewService(cfg)
// Get all tasks
s.AddTool(mcp.NewTool("get-todo-tasks",
mcp.WithDescription("Get all tasks from TODO.md"),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
todos, err := todoService.GetAll()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get todos: %w", err)), nil
}
result, err := newToolResultJSON(todos)
return result, err
},
)
// Get tasks for a specific version
s.AddTool(mcp.NewTool("get-todo-tasks-by-version",
mcp.WithDescription("Get tasks for a specific version from TODO.md"),
mcp.WithString("version",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
todos, err := todoService.GetByVersion(version)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get todos for version %s: %w", version, err)), nil
}
if todos == nil {
return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
}
result, err := newToolResultJSON(todos)
return result, err
},
)
// Add a new task
s.AddTool(mcp.NewTool("add-todo-task",
mcp.WithDescription("Add a new task to TODO.md"),
mcp.WithString("version",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
),
mcp.WithString("description",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Task description")),
),
mcp.WithString("parent_id",
mcp.PropertyOption(mcp.Description("ID of parent task (for subtasks)")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
description, ok := request.Params.Arguments["description"].(string)
if !ok {
return mcp.NewToolResultError("description parameter must be a string"), nil
}
var parentID string
if parent, ok := request.Params.Arguments["parent_id"].(string); ok {
parentID = parent
}
taskID, err := todoService.AddTask(version, description, parentID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
}
result, err := newToolResultJSON(map[string]string{"id": taskID})
return result, err
},
)
// Update an existing task
toolSchema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "Task ID",
},
"completed": map[string]interface{}{
"type": "boolean",
"description": "Task completion status",
},
"description": map[string]interface{}{
"type": "string",
"description": "Updated task description",
},
},
"required": []string{"id"},
}
schemaBytes, _ := json.Marshal(toolSchema)
s.AddTool(mcp.NewToolWithRawSchema("update-todo-task",
"Update an existing task in TODO.md",
json.RawMessage(schemaBytes)),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
taskID, ok := request.Params.Arguments["id"].(string)
if !ok {
return mcp.NewToolResultError("id parameter must be a string"), nil
}
var completed *bool
if c, ok := request.Params.Arguments["completed"].(bool); ok {
completed = &c
}
var description *string
if d, ok := request.Params.Arguments["description"].(string); ok {
description = &d
}
if completed == nil && description == nil {
return mcp.NewToolResultError("at least one of completed or description must be provided"), nil
}
err := todoService.UpdateTask(taskID, completed, description)
if err != nil {
if err == todo.ErrTaskNotFound {
return mcp.NewToolResultError(fmt.Sprintf("task not found: %s", taskID)), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to update task: %w", err)), nil
}
result, err := newToolResultJSON(map[string]bool{"success": true})
return result, err
},
)
// Add a new version section
s.AddTool(mcp.NewTool("add-todo-version",
mcp.WithDescription("Add a new version section to TODO.md"),
mcp.WithString("version",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
err := todoService.AddVersion(version)
if err != nil {
if err == todo.ErrVersionExists {
return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to add version: %w", err)), nil
}
result, err := newToolResultJSON(map[string]bool{"success": true})
return result, err
},
)
// Import and format an existing TODO.md
s.AddTool(mcp.NewTool("import-todo",
mcp.WithDescription("Import and format an existing TODO.md file"),
mcp.WithString("source_path",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Path to the source TODO.md file")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
sourcePath, ok := request.Params.Arguments["source_path"].(string)
if !ok {
return mcp.NewToolResultError("source_path parameter must be a string"), nil
}
// Create an absolute path if a relative path is provided
if !filepath.IsAbs(sourcePath) {
cwd, err := os.Getwd()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
}
sourcePath = filepath.Join(cwd, sourcePath)
}
// Read the source file
content, err := os.ReadFile(sourcePath)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
}
// Split into lines
lines := strings.Split(string(content), "\n")
// Parse the content
todoList, err := todoService.GetAll()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to load existing TODO file: %w", err)), nil
}
// Only proceed if the current TODO.md is empty
if len(todoList.Versions) > 0 {
return mcp.NewToolResultError("TODO.md already exists and has content, cannot import"), nil
}
// Process each line
var currentVersion string
var tasks []struct {
Version string
Description string
Completed bool
}
for _, line := range lines {
// Skip empty lines and title
if line == "" || line == "# TODO" {
continue
}
// Check for version headers
if strings.HasPrefix(line, "## ") {
versionStr := strings.TrimPrefix(line, "## ")
// Handle both v1.0.0 and 1.0.0 formats
if strings.HasPrefix(versionStr, "v") {
currentVersion = strings.TrimPrefix(versionStr, "v")
} else {
currentVersion = versionStr
}
if currentVersion != "" {
// Add the version
err := todoService.AddVersion(currentVersion)
if err != nil && err != todo.ErrVersionExists {
return mcp.NewToolResultError(fmt.Sprintf("failed to add version %s: %w", currentVersion, err)), nil
}
}
continue
}
// Check for task lines
if strings.Contains(line, "- [ ]") || strings.Contains(line, "- [x]") {
completed := strings.Contains(line, "- [x]")
var description string
if completed {
description = strings.TrimSpace(strings.Split(line, "- [x]")[1])
} else {
description = strings.TrimSpace(strings.Split(line, "- [ ]")[1])
}
if currentVersion != "" && description != "" {
tasks = append(tasks, struct {
Version string
Description string
Completed bool
}{
Version: currentVersion,
Description: description,
Completed: completed,
})
}
}
}
// Add all tasks
for _, t := range tasks {
taskID, err := todoService.AddTask(t.Version, t.Description, "")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
}
if t.Completed {
completed := true
err = todoService.UpdateTask(taskID, &completed, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to mark task as completed: %w", err)), nil
}
}
}
result, err := newToolResultJSON(map[string]interface{}{
"success": true,
"versions_added": len(todoList.Versions),
"tasks_added": len(tasks),
})
return result, err
},
)
// Get all changelog items
s.AddTool(mcp.NewTool("get-changelog",
mcp.WithDescription("Get all changelog entries"),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
changelogEntries, err := changelogService.GetAll()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entries: %w", err)), nil
}
result, err := newToolResultJSON(changelogEntries)
return result, err
},
)
// Get changelog items for a specific version
s.AddTool(mcp.NewTool("get-changelog-by-version",
mcp.WithDescription("Get changelog entries for a specific version"),
mcp.WithString("version",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
entry, err := changelogService.GetByVersion(version)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entry for version %s: %w", version, err)), nil
}
if entry == nil {
return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
}
result, err := newToolResultJSON(entry)
return result, err
},
)
// Add a new changelog version entry - using raw schema
changelogSchema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"version": map[string]interface{}{
"type": "string",
"description": "Version string (e.g., '1.0.0')",
},
"date": map[string]interface{}{
"type": "string",
"description": "Release date (YYYY-MM-DD format)",
},
"added": map[string]interface{}{
"type": "array",
"description": "List of new features added",
"items": map[string]interface{}{
"type": "string",
},
},
"changed": map[string]interface{}{
"type": "array",
"description": "List of changes to existing functionality",
"items": map[string]interface{}{
"type": "string",
},
},
"deprecated": map[string]interface{}{
"type": "array",
"description": "List of deprecated features",
"items": map[string]interface{}{
"type": "string",
},
},
"removed": map[string]interface{}{
"type": "array",
"description": "List of removed features",
"items": map[string]interface{}{
"type": "string",
},
},
"fixed": map[string]interface{}{
"type": "array",
"description": "List of bug fixes",
"items": map[string]interface{}{
"type": "string",
},
},
"security": map[string]interface{}{
"type": "array",
"description": "List of security fixes",
"items": map[string]interface{}{
"type": "string",
},
},
},
"required": []string{"version"},
}
changelogSchemaBytes, _ := json.Marshal(changelogSchema)
s.AddTool(mcp.NewToolWithRawSchema("add-changelog-entry",
"Add a new changelog version entry",
json.RawMessage(changelogSchemaBytes)),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
date, ok := request.Params.Arguments["date"].(string)
if !ok {
// Default to today's date
date = time.Now().Format("2006-01-02")
}
// Process content sections
content := &changelog.ChangelogContent{}
if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
content.Added = added
}
if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
content.Changed = changed
}
if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
content.Deprecated = deprecated
}
if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
content.Removed = removed
}
if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
content.Fixed = fixed
}
if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
content.Security = security
}
err := changelogService.AddEntry(version, date, content)
if err != nil {
if err == changelog.ErrVersionExists {
return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
}
result, err := newToolResultJSON(map[string]bool{"success": true})
return result, err
},
)
// Update existing changelog entry - using raw schema
updateChangelogSchema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"version": map[string]interface{}{
"type": "string",
"description": "Version string (e.g., '1.0.0')",
},
"date": map[string]interface{}{
"type": "string",
"description": "Updated release date (YYYY-MM-DD format)",
},
"added": map[string]interface{}{
"type": "array",
"description": "Updated list of new features added",
"items": map[string]interface{}{
"type": "string",
},
},
"changed": map[string]interface{}{
"type": "array",
"description": "Updated list of changes to existing functionality",
"items": map[string]interface{}{
"type": "string",
},
},
"deprecated": map[string]interface{}{
"type": "array",
"description": "Updated list of deprecated features",
"items": map[string]interface{}{
"type": "string",
},
},
"removed": map[string]interface{}{
"type": "array",
"description": "Updated list of removed features",
"items": map[string]interface{}{
"type": "string",
},
},
"fixed": map[string]interface{}{
"type": "array",
"description": "Updated list of bug fixes",
"items": map[string]interface{}{
"type": "string",
},
},
"security": map[string]interface{}{
"type": "array",
"description": "Updated list of security fixes",
"items": map[string]interface{}{
"type": "string",
},
},
},
"required": []string{"version"},
}
updateChangelogSchemaBytes, _ := json.Marshal(updateChangelogSchema)
s.AddTool(mcp.NewToolWithRawSchema("update-changelog-entry",
"Update an existing changelog entry",
json.RawMessage(updateChangelogSchemaBytes)),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
date, ok := request.Params.Arguments["date"].(string)
if !ok {
date = ""
}
// Process content sections
content := &changelog.ChangelogContent{}
if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
content.Added = added
}
if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
content.Changed = changed
}
if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
content.Deprecated = deprecated
}
if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
content.Removed = removed
}
if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
content.Fixed = fixed
}
if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
content.Security = security
}
err := changelogService.UpdateEntry(version, date, content)
if err != nil {
if err == changelog.ErrVersionNotFound {
return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to update changelog entry: %w", err)), nil
}
result, err := newToolResultJSON(map[string]bool{"success": true})
return result, err
},
)
// Import and format an existing CHANGELOG.md
s.AddTool(mcp.NewTool("import-changelog",
mcp.WithDescription("Import and format an existing CHANGELOG.md file"),
mcp.WithString("source_path",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Path to the source CHANGELOG.md file")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
sourcePath, ok := request.Params.Arguments["source_path"].(string)
if !ok {
return mcp.NewToolResultError("source_path parameter must be a string"), nil
}
// Create an absolute path if a relative path is provided
if !filepath.IsAbs(sourcePath) {
cwd, err := os.Getwd()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
}
sourcePath = filepath.Join(cwd, sourcePath)
}
// Read the source file
content, err := os.ReadFile(sourcePath)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
}
// Split into lines
lines := strings.Split(string(content), "\n")
// Parse the content
changelogEntries, err := changelogService.GetAll()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to load existing CHANGELOG file: %w", err)), nil
}
// Only proceed if the current CHANGELOG.md is empty
if len(changelogEntries.Versions) > 0 {
return mcp.NewToolResultError("CHANGELOG.md already exists and has content, cannot import"), nil
}
// Process the content
var currentVersion string
var currentDate string
var currentSection string
var entriesAdded int
var added []string
var changed []string
var deprecated []string
var removed []string
var fixed []string
var security []string
for _, line := range lines {
// Skip empty lines and title
if line == "" || line == "# Changelog" {
continue
}
// Check for version headers
if strings.HasPrefix(line, "## [") && strings.Contains(line, "] - ") {
// Save the previous version if exists
if currentVersion != "" {
content := &changelog.ChangelogContent{
Added: added,
Changed: changed,
Deprecated: deprecated,
Removed: removed,
Fixed: fixed,
Security: security,
}
err := changelogService.AddEntry(currentVersion, currentDate, content)
if err != nil && err != changelog.ErrVersionExists {
return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
}
entriesAdded++
// Reset for next version
added = nil
changed = nil
deprecated = nil
removed = nil
fixed = nil
security = nil
}
// Parse version and date
parts := strings.Split(line, "] - ")
versionStr := strings.TrimPrefix(parts[0], "## [")
currentVersion = versionStr
currentDate = parts[1]
currentSection = ""
continue
}
// Check for section headers
if strings.HasPrefix(line, "### ") {
currentSection = strings.ToLower(strings.TrimPrefix(line, "### "))
continue
}
// Process list items
if strings.HasPrefix(line, "- ") && currentSection != "" {
item := strings.TrimPrefix(line, "- ")
switch currentSection {
case "added":
added = append(added, item)
case "changed":
changed = append(changed, item)
case "deprecated":
deprecated = append(deprecated, item)
case "removed":
removed = append(removed, item)
case "fixed":
fixed = append(fixed, item)
case "security":
security = append(security, item)
}
}
}
// Add the last version if exists
if currentVersion != "" {
content := &changelog.ChangelogContent{
Added: added,
Changed: changed,
Deprecated: deprecated,
Removed: removed,
Fixed: fixed,
Security: security,
}
err := changelogService.AddEntry(currentVersion, currentDate, content)
if err != nil && err != changelog.ErrVersionExists {
return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
}
entriesAdded++
}
result, err := newToolResultJSON(map[string]interface{}{
"success": true,
"entries_added": entriesAdded,
})
return result, err
},
)
// Generate a new CHANGELOG.md based on completed tasks in TODO.md
s.AddTool(mcp.NewTool("generate-changelog-from-todo",
mcp.WithDescription("Generate a new CHANGELOG.md entry based on completed tasks in TODO.md"),
mcp.WithString("version",
mcp.PropertyOption(mcp.Required()),
mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
),
mcp.WithString("date",
mcp.PropertyOption(mcp.Description("Release date (YYYY-MM-DD format)")),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version, ok := request.Params.Arguments["version"].(string)
if !ok {
return mcp.NewToolResultError("version parameter must be a string"), nil
}
date, ok := request.Params.Arguments["date"].(string)
if !ok {
// Default to today's date
date = time.Now().Format("2006-01-02")
}
// Get tasks for the specified version
versionTasks, err := todoService.GetByVersion(version)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get tasks for version %s: %w", version, err)), nil
}
if versionTasks == nil {
return mcp.NewToolResultError(fmt.Sprintf("version not found in TODO.md: %s", version)), nil
}
// Check if this version already exists in CHANGELOG
existingEntry, err := changelogService.GetByVersion(version)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to check if changelog entry exists: %w", err)), nil
}
if existingEntry != nil {
return mcp.NewToolResultError(fmt.Sprintf("changelog entry already exists for version %s", version)), nil
}
// Categorize completed tasks
var added []string
var changed []string
var fixed []string
// Helper function to process tasks recursively
var processTasks func(tasks []*todo.Task)
processTasks = func(tasks []*todo.Task) {
for _, task := range tasks {
if task.Completed {
// Categorize based on common prefixes/keywords
desc := task.Description
lowerDesc := strings.ToLower(desc)
if strings.HasPrefix(lowerDesc, "add") ||
strings.HasPrefix(lowerDesc, "implement") ||
strings.HasPrefix(lowerDesc, "create") {
added = append(added, desc)
} else if strings.HasPrefix(lowerDesc, "update") ||
strings.HasPrefix(lowerDesc, "change") ||
strings.HasPrefix(lowerDesc, "modify") ||
strings.HasPrefix(lowerDesc, "enhance") ||
strings.HasPrefix(lowerDesc, "improve") {
changed = append(changed, desc)
} else if strings.HasPrefix(lowerDesc, "fix") ||
strings.HasPrefix(lowerDesc, "correct") ||
strings.HasPrefix(lowerDesc, "resolve") {
fixed = append(fixed, desc)
} else {
// Default to Added if cannot categorize
added = append(added, desc)
}
}
// Process subtasks
if len(task.SubTasks) > 0 {
processTasks(task.SubTasks)
}
}
}
processTasks(versionTasks.Tasks)
// Create changelog entry
content := &changelog.ChangelogContent{
Added: added,
Changed: changed,
Fixed: fixed,
}
err = changelogService.AddEntry(version, date, content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
}
result, err := newToolResultJSON(map[string]interface{}{
"success": true,
"added_items": len(added),
"changed_items": len(changed),
"fixed_items": len(fixed),
})
return result, err
},
)
}
// Helper function to extract string arrays from the arguments
func getStringArray(args map[string]any, key string) ([]string, bool) {
if val, ok := args[key]; ok {
if arr, ok := val.([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, item := range arr {
if strItem, ok := item.(string); ok {
result = append(result, strItem)
}
}
return result, true
} else if arrStr, ok := val.([]string); ok {
return arrStr, true
}
}
return nil, false
}
```