diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..0050615
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/obsidian-mcp.iml b/.idea/obsidian-mcp.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/obsidian-mcp.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..dc40868
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,134 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Development Commands
+
+### Building
+```bash
+go build -o obsidian-mcp ./cmd/obsidian-mcp
+```
+
+### Testing
+```bash
+# Run all tests
+go test ./...
+
+# Run tests with coverage
+go test -cover ./...
+
+# Run tests for specific package
+go test ./internal/domain/
+
+# Run specific test
+go test -run TestNoteName ./internal/domain/
+```
+
+### Running
+```bash
+# Basic run with vault path
+./obsidian-mcp --vault /path/to/vault
+
+# With config file
+./obsidian-mcp --config config.yaml
+
+# With environment variables
+VAULT_PATH=/path/to/vault LOG_LEVEL=debug ./obsidian-mcp
+```
+
+### Dependencies
+```bash
+# Download dependencies
+go mod download
+
+# Update dependencies
+go mod tidy
+
+# Add new dependency
+go get github.com/package/name
+```
+
+## Architecture Overview
+
+This is an Obsidian MCP (Model Context Protocol) Server implementing **Clean Architecture** with strict dependency inversion. The codebase follows Domain-Driven Design principles.
+
+### Layer Dependencies (Dependency Rule)
+All dependencies point inward toward the domain:
+- **Domain Layer** (innermost): Pure business logic, no external dependencies
+- **Repository Layer**: Interfaces defining data access contracts
+- **Use Case Layer**: Business orchestration, depends only on domain + repository interfaces
+- **Infrastructure Layer**: Concrete implementations (filesystem, git, parsers)
+- **Adapter Layer**: MCP protocol handlers, DTOs, external interface adapters
+
+### Key Domain Entities
+
+**Note**: Central entity representing an Obsidian note with:
+- Path validation (must end in .md, no path traversal)
+- Content with automatic extraction of WikiLinks and Tags
+- Frontmatter (YAML metadata)
+- Timestamps (created/modified)
+
+**Vault**: Aggregate root managing collections of notes with:
+- Graph of note relationships
+- Tag indexing with counts
+- Statistics calculation
+
+**WikiLink**: Value object for `[[Internal Links]]` with support for:
+- Aliases: `[[target|display text]]`
+- Sections: `[[note#section]]`
+
+**Tag**: Value object for `#tags` with:
+- Nested tag support: `#project/ai/research`
+- Validation (no spaces, proper format)
+
+**Graph**: Thread-safe adjacency list for note relationships
+
+### Repository Pattern
+All data access goes through interfaces in `internal/repository/`:
+- **NoteRepository**: CRUD operations for notes
+- **GitRepository**: Version control operations
+- **VaultRepository**: Vault-level operations and statistics
+- **SearchIndex**: Full-text search capabilities
+- **GraphIndex**: Backlink/outlink indexing
+- **TagIndex**: Tag-based search and operations
+
+### Configuration System
+Uses `internal/config/config.go` with:
+- YAML file configuration
+- Environment variable overrides
+- Validation with sensible defaults
+- Support for vault path, git settings, caching, logging, performance tuning
+
+### Error Handling
+Domain errors defined in `internal/domain/errors.go`:
+- `ErrNoteNotFound`, `ErrNoteAlreadyExists`
+- `ErrInvalidPath`, `ErrInvalidTag`
+- `ErrCircularLink`
+
+### Key Architectural Patterns
+
+1. **Dependency Inversion**: Use cases depend on repository interfaces, not implementations
+2. **Entity Encapsulation**: Domain entities manage their own business rules
+3. **Value Objects**: Immutable types like WikiLink, Tag, Frontmatter
+4. **Repository Pattern**: Clean separation between business logic and data access
+5. **Configuration as Code**: Comprehensive config system with validation
+
+### Implementation Status
+Currently implemented:
+- ✅ Complete domain layer with all entities
+- ✅ All repository interfaces
+- ✅ Configuration system
+- ✅ CLI entry point
+
+Still needed (per design.md roadmap):
+- Infrastructure implementations (filesystem, git, markdown parsing)
+- Use case implementations
+- MCP adapter layer with 45+ tools
+- Index implementations (search, graph, tag)
+
+### MCP Integration
+When implementing MCP tools, they should:
+- Live in `internal/adapter/mcp/tools/`
+- Use DTOs from `internal/adapter/dto/`
+- Call use cases, never repositories directly
+- Handle MCP protocol specifics while keeping business logic in use cases
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cf7f533
--- /dev/null
+++ b/README.md
@@ -0,0 +1,208 @@
+# Obsidian MCP Server
+
+A lightweight, high-performance Model Context Protocol (MCP) server for Obsidian vaults written in Go.
+
+## Features
+
+- **Full Obsidian Support**: Read, create, update, and delete notes with frontmatter, wikilinks, and tags
+- **Git Integration**: Automatic version control with commit, pull, and push operations
+- **Fast Search**: Full-text search, tag-based search, and frontmatter filtering
+- **Graph Navigation**: Backlinks, outlinks, and graph traversal
+- **45+ MCP Tools**: Comprehensive API for note management and vault operations
+- **High Performance**: < 100ms read operations, supports vaults with 10,000+ notes
+- **Clean Architecture**: Domain-driven design with clear separation of concerns
+
+## Installation
+
+### Binary Release
+```bash
+# Download the latest release for your platform
+curl -L https://github.com/user/obsidian-mcp-server/releases/latest/download/obsidian-mcp-linux -o obsidian-mcp
+chmod +x obsidian-mcp
+```
+
+### Go Install
+```bash
+go install github.com/user/obsidian-mcp-server/cmd/obsidian-mcp@latest
+```
+
+### Docker
+```bash
+docker run -v /path/to/vault:/vault ghcr.io/user/obsidian-mcp-server:latest
+```
+
+## Quick Start
+
+1. **Configure MCP Client** (Claude Desktop example):
+```json
+{
+ "mcpServers": {
+ "obsidian": {
+ "command": "obsidian-mcp",
+ "args": ["--vault", "/path/to/your/vault"],
+ "env": {
+ "LOG_LEVEL": "info"
+ }
+ }
+ }
+}
+```
+
+2. **Start the server**:
+```bash
+obsidian-mcp --vault /path/to/your/vault
+```
+
+3. **Use with Claude**: The server will be available through the MCP protocol with 45+ tools for note management.
+
+## Configuration
+
+### Environment Variables
+```bash
+export VAULT_PATH=/path/to/vault # Required: Path to Obsidian vault
+export LOG_LEVEL=info # Optional: debug, info, warn, error
+export GIT_ENABLED=true # Optional: Enable git operations
+export GIT_AUTO_PUSH=false # Optional: Auto-push changes
+export CACHE_ENABLED=true # Optional: Enable caching
+```
+
+### Config File (config.yaml)
+```yaml
+server:
+ name: obsidian-mcp
+ transport: stdio
+
+vault:
+ path: /path/to/vault
+ exclude_paths:
+ - .obsidian/
+ - .git/
+ - .trash/
+
+git:
+ enabled: true
+ auto_pull: true
+ auto_push: false
+ auto_commit: true
+
+logging:
+ level: info
+ format: text
+```
+
+## MCP Tools
+
+The server provides 45+ MCP tools organized into categories:
+
+### File Operations (5)
+- `read_note` - Read note with metadata
+- `create_note` - Create new note
+- `update_note` - Update existing note
+- `delete_note` - Delete note
+- `rename_note` - Rename note and update links
+
+### Search Operations (3)
+- `search_content` - Full-text search
+- `search_by_tags` - Search by tags
+- `search_by_frontmatter` - Search by frontmatter
+
+### Link Operations (5)
+- `get_backlinks` - Get notes linking to this note
+- `get_outlinks` - Get notes this note links to
+- `get_graph` - Get graph neighborhood
+- `find_broken_links` - Find broken wikilinks
+- `update_links` - Update links after rename
+
+### Tag Operations (4)
+- `get_all_tags` - List all tags with counts
+- `add_tags` - Add tags to notes
+- `remove_tags` - Remove tags from notes
+- `rename_tag` - Rename tag across vault
+
+### Git Operations (5)
+- `git_status` - Get git status
+- `git_commit` - Commit changes
+- `git_pull` - Pull from remote
+- `git_push` - Push to remote
+- `git_history` - Get commit history
+
+### Vault Operations (5+)
+- `get_vault_stats` - Get vault statistics
+- `list_notes` - List all notes
+- `get_vault_structure` - Get directory structure
+- `find_orphaned_notes` - Find notes without links
+- And more...
+
+## Development
+
+### Project Structure
+```
+obsidian-mcp-server/
+├── cmd/obsidian-mcp/ # Entry point
+├── internal/
+│ ├── domain/ # Domain entities
+│ ├── repository/ # Repository interfaces
+│ ├── usecase/ # Business logic
+│ ├── infrastructure/ # External dependencies
+│ ├── adapter/mcp/ # MCP protocol layer
+│ └── config/ # Configuration
+├── pkg/ # Public packages
+└── test/ # Tests
+```
+
+### Building
+```bash
+go build -o obsidian-mcp ./cmd/obsidian-mcp
+```
+
+### Testing
+```bash
+go test ./...
+```
+
+### Contributing
+1. Fork the repository
+2. Create feature branch
+3. Add tests for new functionality
+4. Ensure all tests pass
+5. Submit pull request
+
+## Performance
+
+### Target Metrics
+- Read operations: < 100ms
+- Create/Update: < 200ms
+- Search (5000 notes): < 500ms
+- Memory usage: ~200MB for 5000 notes
+
+### Scalability
+- Recommended: Up to 10,000 notes
+- Maximum: 50,000 notes (performance may degrade)
+- Vault size: Up to 1GB
+
+## License
+
+MIT License - see [LICENSE](LICENSE) file for details.
+
+## Support
+
+- **Issues**: [GitHub Issues](https://github.com/user/obsidian-mcp-server/issues)
+- **Documentation**: [Wiki](https://github.com/user/obsidian-mcp-server/wiki)
+- **Discussions**: [GitHub Discussions](https://github.com/user/obsidian-mcp-server/discussions)
+
+## Roadmap
+
+### v1.1.0
+- [ ] Semantic search with embeddings
+- [ ] Real-time file watching
+- [ ] Web UI for monitoring
+
+### v1.2.0
+- [ ] Custom parsers
+- [ ] Advanced git operations
+- [ ] Performance improvements
+
+### v2.0.0
+- [ ] Multi-vault support
+- [ ] Collaboration features
+- [ ] Cloud sync integration
\ No newline at end of file
diff --git a/cmd/obsidian-mcp/main.go b/cmd/obsidian-mcp/main.go
new file mode 100644
index 0000000..0621498
--- /dev/null
+++ b/cmd/obsidian-mcp/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/user/obsidian-mcp-server/internal/config"
+)
+
+var (
+ version = "1.0.0"
+ commit = "dev"
+ date = "unknown"
+)
+
+func main() {
+ var (
+ configPath = flag.String("config", "", "Path to config file")
+ vaultPath = flag.String("vault", "", "Path to Obsidian vault")
+ showVersion = flag.Bool("version", false, "Show version information")
+ showHelp = flag.Bool("help", false, "Show help")
+ )
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Printf("Obsidian MCP Server\n")
+ fmt.Printf("Version: %s\n", version)
+ fmt.Printf("Commit: %s\n", commit)
+ fmt.Printf("Built: %s\n", date)
+ os.Exit(0)
+ }
+
+ if *showHelp {
+ fmt.Printf("Obsidian MCP Server - A Model Context Protocol server for Obsidian vaults\n\n")
+ fmt.Printf("Usage:\n")
+ fmt.Printf(" obsidian-mcp [flags]\n\n")
+ fmt.Printf("Flags:\n")
+ flag.PrintDefaults()
+ fmt.Printf("\nEnvironment Variables:\n")
+ fmt.Printf(" VAULT_PATH - Path to Obsidian vault\n")
+ fmt.Printf(" CONFIG_PATH - Path to config file\n")
+ fmt.Printf(" LOG_LEVEL - Logging level (debug, info, warn, error)\n")
+ fmt.Printf(" GIT_ENABLED - Enable git operations (true/false)\n")
+ fmt.Printf(" GIT_AUTO_PUSH - Auto-push changes (true/false)\n")
+ fmt.Printf(" CACHE_ENABLED - Enable caching (true/false)\n")
+ os.Exit(0)
+ }
+
+ cfg, err := loadConfig(*configPath, *vaultPath)
+ if err != nil {
+ log.Fatalf("Failed to load config: %v", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Setup signal handling
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ <-sigChan
+ log.Println("Received shutdown signal")
+ cancel()
+ }()
+
+ log.Printf("Starting Obsidian MCP Server v%s", version)
+ log.Printf("Vault path: %s", cfg.Vault.Path)
+ log.Printf("Transport: %s", cfg.Server.Transport)
+
+ // TODO: Initialize server, repositories, and use cases
+ // server := NewMCPServer(cfg)
+ // if err := server.Start(ctx); err != nil {
+ // log.Fatalf("Server failed: %v", err)
+ // }
+
+ log.Println("Server started successfully")
+
+ <-ctx.Done()
+ log.Println("Shutting down...")
+}
+
+func loadConfig(configPath, vaultPath string) (*config.Config, error) {
+ if configPath == "" {
+ configPath = os.Getenv("CONFIG_PATH")
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ envCfg, err := config.LoadConfigFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ if vaultPath != "" {
+ cfg.Vault.Path = vaultPath
+ } else if envCfg.Vault.Path != "" {
+ cfg.Vault.Path = envCfg.Vault.Path
+ }
+
+ cfg.Logging.Level = envCfg.Logging.Level
+ cfg.Git.Enabled = envCfg.Git.Enabled
+ cfg.Git.AutoPush = envCfg.Git.AutoPush
+ cfg.Cache.Enabled = envCfg.Cache.Enabled
+
+ return cfg, cfg.Validate()
+}
\ No newline at end of file
diff --git a/design.md b/design.md
index 0648de1..9fd4a01 100644
--- a/design.md
+++ b/design.md
@@ -294,20 +294,20 @@ classDiagram
-string path
-string content
-Frontmatter frontmatter
- -[]WikiLink outlinks
- -[]Tag tags
+ -WikiLink[] outlinks
+ -Tag[] tags
-time createdAt
-time modifiedAt
+NewNote(path, content) Note
+AddTag(tag) error
+RemoveTag(tag) error
+UpdateContent(content) error
- +ExtractLinks() []WikiLink
+ +ExtractLinks() WikiLink[]
}
class Vault {
-string rootPath
- -[]Note notes
+ -Note[] notes
-Graph graph
-map tags
+NewVault(path) Vault
@@ -317,7 +317,7 @@ classDiagram
class Frontmatter {
-map data
- +Get(key) interface{}
+ +Get(key) any
+Set(key, value) error
+Merge(other) Frontmatter
}
@@ -340,8 +340,8 @@ classDiagram
class Graph {
-map adjacencyList
+AddEdge(from, to)
- +GetBacklinks(path) []string
- +GetOutlinks(path) []string
+ +GetBacklinks(path) string[]
+ +GetOutlinks(path) string[]
}
Vault "1" *-- "0..*" Note
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..23f9725
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,32 @@
+module github.com/user/obsidian-mcp-server
+
+go 1.23.0
+
+require (
+ github.com/go-git/go-git/v5 v5.11.0
+ github.com/modelcontextprotocol/go-sdk v0.1.0
+ github.com/yuin/goldmark v1.6.0
+ go.abhg.dev/goldmark/frontmatter v0.1.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
+ github.com/acomagu/bufpipe v1.0.4 // indirect
+ github.com/cloudflare/circl v1.3.3 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/pjbgf/sha1cd v0.3.0 // indirect
+ github.com/sergi/go-diff v1.1.0 // indirect
+ github.com/skeema/knownhosts v1.2.1 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ golang.org/x/crypto v0.16.0 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e57be95
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,89 @@
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/modelcontextprotocol/go-sdk v0.1.0/go.mod h1:DcXfbr7yl7e35oMpzHfKw2nUYRjhIGS2uou/6tdsTB0=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.abhg.dev/goldmark/frontmatter v0.1.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..881ba56
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,241 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Server ServerConfig `yaml:"server"`
+ Vault VaultConfig `yaml:"vault"`
+ Git GitConfig `yaml:"git"`
+ Index IndexConfig `yaml:"index"`
+ Search SearchConfig `yaml:"search"`
+ Cache CacheConfig `yaml:"cache"`
+ Logging LoggingConfig `yaml:"logging"`
+ Performance PerformanceConfig `yaml:"performance"`
+ Security SecurityConfig `yaml:"security"`
+}
+
+type ServerConfig struct {
+ Name string `yaml:"name"`
+ Version string `yaml:"version"`
+ Transport string `yaml:"transport"` // "stdio" or "sse"
+}
+
+type VaultConfig struct {
+ Path string `yaml:"path"`
+ ExcludePaths []string `yaml:"exclude_paths"`
+ WatchChanges bool `yaml:"watch_changes"`
+}
+
+type GitConfig struct {
+ Enabled bool `yaml:"enabled"`
+ AutoPull bool `yaml:"auto_pull"`
+ AutoPush bool `yaml:"auto_push"`
+ AutoCommit bool `yaml:"auto_commit"`
+ CommitPrefix string `yaml:"commit_prefix"`
+ Remote string `yaml:"remote"`
+ Branch string `yaml:"branch"`
+}
+
+type IndexConfig struct {
+ BuildOnStartup bool `yaml:"build_on_startup"`
+ RebuildInterval time.Duration `yaml:"rebuild_interval"`
+ MaxNotesInMemory int `yaml:"max_notes_in_memory"`
+}
+
+type SearchConfig struct {
+ MaxResults int `yaml:"max_results"`
+ ContextLines int `yaml:"context_lines"`
+ CaseSensitive bool `yaml:"case_sensitive"`
+}
+
+type CacheConfig struct {
+ Enabled bool `yaml:"enabled"`
+ MaxSize int `yaml:"max_size"`
+ TTL time.Duration `yaml:"ttl"`
+}
+
+type LoggingConfig struct {
+ Level string `yaml:"level"` // debug, info, warn, error
+ Format string `yaml:"format"` // text or json
+ File string `yaml:"file"`
+}
+
+type PerformanceConfig struct {
+ MaxConcurrentOperations int `yaml:"max_concurrent_operations"`
+ ReadTimeout time.Duration `yaml:"read_timeout"`
+ WriteTimeout time.Duration `yaml:"write_timeout"`
+}
+
+type RateLimitConfig struct {
+ Enabled bool `yaml:"enabled"`
+ RequestsPerSecond int `yaml:"requests_per_second"`
+ Burst int `yaml:"burst"`
+}
+
+type SecurityConfig struct {
+ RateLimit RateLimitConfig `yaml:"rate_limit"`
+ MaxFileSize string `yaml:"max_file_size"`
+}
+
+func NewDefaultConfig() *Config {
+ return &Config{
+ Server: ServerConfig{
+ Name: "obsidian-mcp",
+ Version: "1.0.0",
+ Transport: "stdio",
+ },
+ Vault: VaultConfig{
+ Path: "",
+ ExcludePaths: []string{
+ ".obsidian/",
+ ".git/",
+ ".trash/",
+ },
+ WatchChanges: true,
+ },
+ Git: GitConfig{
+ Enabled: true,
+ AutoPull: true,
+ AutoPush: false,
+ AutoCommit: true,
+ CommitPrefix: "[MCP]",
+ Remote: "origin",
+ Branch: "main",
+ },
+ Index: IndexConfig{
+ BuildOnStartup: true,
+ RebuildInterval: 5 * time.Minute,
+ MaxNotesInMemory: 10000,
+ },
+ Search: SearchConfig{
+ MaxResults: 50,
+ ContextLines: 2,
+ CaseSensitive: false,
+ },
+ Cache: CacheConfig{
+ Enabled: true,
+ MaxSize: 1000,
+ TTL: 5 * time.Minute,
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ Format: "text",
+ File: "",
+ },
+ Performance: PerformanceConfig{
+ MaxConcurrentOperations: 10,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ },
+ Security: SecurityConfig{
+ RateLimit: RateLimitConfig{
+ Enabled: true,
+ RequestsPerSecond: 10,
+ Burst: 20,
+ },
+ MaxFileSize: "10MB",
+ },
+ }
+}
+
+func LoadConfig(path string) (*Config, error) {
+ config := NewDefaultConfig()
+
+ if path == "" {
+ return config, nil
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return config, nil
+ }
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ if err := yaml.Unmarshal(data, config); err != nil {
+ return nil, fmt.Errorf("failed to parse config file: %w", err)
+ }
+
+ if err := config.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid config: %w", err)
+ }
+
+ return config, nil
+}
+
+func LoadConfigFromEnv() (*Config, error) {
+ config := NewDefaultConfig()
+
+ if vaultPath := os.Getenv("VAULT_PATH"); vaultPath != "" {
+ config.Vault.Path = vaultPath
+ }
+
+ if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
+ config.Logging.Level = logLevel
+ }
+
+ if gitEnabled := os.Getenv("GIT_ENABLED"); gitEnabled == "false" {
+ config.Git.Enabled = false
+ }
+
+ if gitAutoPush := os.Getenv("GIT_AUTO_PUSH"); gitAutoPush == "true" {
+ config.Git.AutoPush = true
+ }
+
+ if cacheEnabled := os.Getenv("CACHE_ENABLED"); cacheEnabled == "false" {
+ config.Cache.Enabled = false
+ }
+
+ return config, config.Validate()
+}
+
+func (c *Config) Validate() error {
+ if c.Vault.Path == "" {
+ return fmt.Errorf("vault path is required")
+ }
+
+ if c.Server.Transport != "stdio" && c.Server.Transport != "sse" {
+ return fmt.Errorf("invalid transport: %s", c.Server.Transport)
+ }
+
+ if c.Search.MaxResults <= 0 {
+ return fmt.Errorf("max results must be positive")
+ }
+
+ if c.Cache.MaxSize <= 0 {
+ return fmt.Errorf("cache max size must be positive")
+ }
+
+ validLogLevels := map[string]bool{
+ "debug": true,
+ "info": true,
+ "warn": true,
+ "error": true,
+ }
+
+ if !validLogLevels[c.Logging.Level] {
+ return fmt.Errorf("invalid log level: %s", c.Logging.Level)
+ }
+
+ return nil
+}
+
+func (c *Config) SaveToFile(path string) error {
+ data, err := yaml.Marshal(c)
+ if err != nil {
+ return fmt.Errorf("failed to marshal config: %w", err)
+ }
+
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/internal/domain/errors.go b/internal/domain/errors.go
new file mode 100644
index 0000000..5ee3f26
--- /dev/null
+++ b/internal/domain/errors.go
@@ -0,0 +1,12 @@
+package domain
+
+import "errors"
+
+var (
+ ErrNoteNotFound = errors.New("note not found")
+ ErrNoteAlreadyExists = errors.New("note already exists")
+ ErrInvalidPath = errors.New("invalid path")
+ ErrEmptyContent = errors.New("content cannot be empty")
+ ErrInvalidTag = errors.New("invalid tag format")
+ ErrCircularLink = errors.New("circular link detected")
+)
\ No newline at end of file
diff --git a/internal/domain/frontmatter.go b/internal/domain/frontmatter.go
new file mode 100644
index 0000000..e4c0c10
--- /dev/null
+++ b/internal/domain/frontmatter.go
@@ -0,0 +1,110 @@
+package domain
+
+import (
+ "fmt"
+ "reflect"
+)
+
+type Frontmatter struct {
+ data map[string]interface{}
+}
+
+func NewFrontmatter() *Frontmatter {
+ return &Frontmatter{
+ data: make(map[string]interface{}),
+ }
+}
+
+func NewFrontmatterFromMap(data map[string]interface{}) *Frontmatter {
+ fm := NewFrontmatter()
+ for k, v := range data {
+ fm.data[k] = v
+ }
+ return fm
+}
+
+func (f *Frontmatter) Get(key string) interface{} {
+ return f.data[key]
+}
+
+func (f *Frontmatter) Set(key string, value interface{}) error {
+ if key == "" {
+ return fmt.Errorf("frontmatter key cannot be empty")
+ }
+ f.data[key] = value
+ return nil
+}
+
+func (f *Frontmatter) Has(key string) bool {
+ _, exists := f.data[key]
+ return exists
+}
+
+func (f *Frontmatter) Delete(key string) {
+ delete(f.data, key)
+}
+
+func (f *Frontmatter) Keys() []string {
+ keys := make([]string, 0, len(f.data))
+ for k := range f.data {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func (f *Frontmatter) Data() map[string]interface{} {
+ result := make(map[string]interface{})
+ for k, v := range f.data {
+ result[k] = v
+ }
+ return result
+}
+
+func (f *Frontmatter) IsEmpty() bool {
+ return len(f.data) == 0
+}
+
+func (f *Frontmatter) Merge(other *Frontmatter) *Frontmatter {
+ merged := NewFrontmatter()
+
+ for k, v := range f.data {
+ merged.data[k] = v
+ }
+
+ for k, v := range other.data {
+ merged.data[k] = v
+ }
+
+ return merged
+}
+
+func (f *Frontmatter) Clone() *Frontmatter {
+ cloned := NewFrontmatter()
+ for k, v := range f.data {
+ cloned.data[k] = deepCopy(v)
+ }
+ return cloned
+}
+
+func deepCopy(src interface{}) interface{} {
+ if src == nil {
+ return nil
+ }
+
+ switch v := src.(type) {
+ case map[string]interface{}:
+ dst := make(map[string]interface{})
+ for key, val := range v {
+ dst[key] = deepCopy(val)
+ }
+ return dst
+ case []interface{}:
+ dst := make([]interface{}, len(v))
+ for i, val := range v {
+ dst[i] = deepCopy(val)
+ }
+ return dst
+ default:
+ return v
+ }
+}
\ No newline at end of file
diff --git a/internal/domain/graph.go b/internal/domain/graph.go
new file mode 100644
index 0000000..116001c
--- /dev/null
+++ b/internal/domain/graph.go
@@ -0,0 +1,117 @@
+package domain
+
+import "sync"
+
+type Graph struct {
+ adjacencyList map[string]map[string]bool
+ mutex sync.RWMutex
+}
+
+func NewGraph() *Graph {
+ return &Graph{
+ adjacencyList: make(map[string]map[string]bool),
+ }
+}
+
+func (g *Graph) AddEdge(from, to string) {
+ g.mutex.Lock()
+ defer g.mutex.Unlock()
+
+ if g.adjacencyList[from] == nil {
+ g.adjacencyList[from] = make(map[string]bool)
+ }
+ g.adjacencyList[from][to] = true
+}
+
+func (g *Graph) RemoveEdge(from, to string) {
+ g.mutex.Lock()
+ defer g.mutex.Unlock()
+
+ if g.adjacencyList[from] != nil {
+ delete(g.adjacencyList[from], to)
+ if len(g.adjacencyList[from]) == 0 {
+ delete(g.adjacencyList, from)
+ }
+ }
+}
+
+func (g *Graph) GetOutlinks(path string) []string {
+ g.mutex.RLock()
+ defer g.mutex.RUnlock()
+
+ var outlinks []string
+ if targets, exists := g.adjacencyList[path]; exists {
+ for target := range targets {
+ outlinks = append(outlinks, target)
+ }
+ }
+ return outlinks
+}
+
+func (g *Graph) GetBacklinks(path string) []string {
+ g.mutex.RLock()
+ defer g.mutex.RUnlock()
+
+ var backlinks []string
+ for source, targets := range g.adjacencyList {
+ if targets[path] {
+ backlinks = append(backlinks, source)
+ }
+ }
+ return backlinks
+}
+
+func (g *Graph) HasEdge(from, to string) bool {
+ g.mutex.RLock()
+ defer g.mutex.RUnlock()
+
+ if targets, exists := g.adjacencyList[from]; exists {
+ return targets[to]
+ }
+ return false
+}
+
+func (g *Graph) RemoveNode(path string) {
+ g.mutex.Lock()
+ defer g.mutex.Unlock()
+
+ delete(g.adjacencyList, path)
+
+ for source, targets := range g.adjacencyList {
+ delete(targets, path)
+ if len(targets) == 0 {
+ delete(g.adjacencyList, source)
+ }
+ }
+}
+
+func (g *Graph) GetAllNodes() []string {
+ g.mutex.RLock()
+ defer g.mutex.RUnlock()
+
+ nodeSet := make(map[string]bool)
+
+ for source := range g.adjacencyList {
+ nodeSet[source] = true
+ }
+
+ for _, targets := range g.adjacencyList {
+ for target := range targets {
+ nodeSet[target] = true
+ }
+ }
+
+ var nodes []string
+ for node := range nodeSet {
+ nodes = append(nodes, node)
+ }
+
+ return nodes
+}
+
+func (g *Graph) Clear() {
+ g.mutex.Lock()
+ defer g.mutex.Unlock()
+
+ g.adjacencyList = make(map[string]map[string]bool)
+}
\ No newline at end of file
diff --git a/internal/domain/note.go b/internal/domain/note.go
new file mode 100644
index 0000000..7cf4b9f
--- /dev/null
+++ b/internal/domain/note.go
@@ -0,0 +1,190 @@
+package domain
+
+import (
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Note struct {
+ path string
+ content string
+ frontmatter *Frontmatter
+ outlinks []*WikiLink
+ tags []*Tag
+ createdAt time.Time
+ modifiedAt time.Time
+}
+
+func NewNote(path, content string) (*Note, error) {
+ if err := validatePath(path); err != nil {
+ return nil, err
+ }
+
+ note := &Note{
+ path: path,
+ content: content,
+ frontmatter: NewFrontmatter(),
+ outlinks: make([]*WikiLink, 0),
+ tags: make([]*Tag, 0),
+ createdAt: time.Now(),
+ modifiedAt: time.Now(),
+ }
+
+ if err := note.extractLinks(); err != nil {
+ return nil, err
+ }
+
+ if err := note.extractTags(); err != nil {
+ return nil, err
+ }
+
+ return note, nil
+}
+
+func (n *Note) Path() string {
+ return n.path
+}
+
+func (n *Note) Content() string {
+ return n.content
+}
+
+func (n *Note) Frontmatter() *Frontmatter {
+ return n.frontmatter
+}
+
+func (n *Note) Outlinks() []*WikiLink {
+ return n.outlinks
+}
+
+func (n *Note) Tags() []*Tag {
+ return n.tags
+}
+
+func (n *Note) CreatedAt() time.Time {
+ return n.createdAt
+}
+
+func (n *Note) ModifiedAt() time.Time {
+ return n.modifiedAt
+}
+
+func (n *Note) UpdateContent(content string) error {
+ n.content = content
+ n.modifiedAt = time.Now()
+
+ n.outlinks = make([]*WikiLink, 0)
+ n.tags = make([]*Tag, 0)
+
+ if err := n.extractLinks(); err != nil {
+ return err
+ }
+
+ return n.extractTags()
+}
+
+func (n *Note) SetFrontmatter(fm *Frontmatter) {
+ n.frontmatter = fm
+ n.modifiedAt = time.Now()
+}
+
+func (n *Note) AddTag(tag *Tag) error {
+ for _, existingTag := range n.tags {
+ if existingTag.Name() == tag.Name() {
+ return nil
+ }
+ }
+
+ n.tags = append(n.tags, tag)
+ n.modifiedAt = time.Now()
+ return nil
+}
+
+func (n *Note) RemoveTag(tagName string) error {
+ for i, tag := range n.tags {
+ if tag.Name() == tagName {
+ n.tags = append(n.tags[:i], n.tags[i+1:]...)
+ n.modifiedAt = time.Now()
+ return nil
+ }
+ }
+ return nil
+}
+
+func (n *Note) HasTag(tagName string) bool {
+ for _, tag := range n.tags {
+ if tag.Name() == tagName {
+ return true
+ }
+ }
+ return false
+}
+
+func (n *Note) ExtractLinks() []*WikiLink {
+ return n.outlinks
+}
+
+func (n *Note) WordCount() int {
+ words := strings.Fields(n.content)
+ return len(words)
+}
+
+func (n *Note) CharacterCount() int {
+ return len(n.content)
+}
+
+func (n *Note) extractLinks() error {
+ linkRegex := regexp.MustCompile(`\[\[([^\]]+)\]\]`)
+ matches := linkRegex.FindAllStringSubmatch(n.content, -1)
+
+ for _, match := range matches {
+ if len(match) >= 2 {
+ link, err := ParseWikiLink(match[1])
+ if err != nil {
+ continue
+ }
+ n.outlinks = append(n.outlinks, link)
+ }
+ }
+
+ return nil
+}
+
+func (n *Note) extractTags() error {
+ tagRegex := regexp.MustCompile(`#([a-zA-Z0-9_/]+)`)
+ matches := tagRegex.FindAllStringSubmatch(n.content, -1)
+
+ for _, match := range matches {
+ if len(match) >= 2 {
+ tag, err := NewTag(match[1])
+ if err != nil {
+ continue
+ }
+ n.AddTag(tag)
+ }
+ }
+
+ return nil
+}
+
+func validatePath(path string) error {
+ if path == "" {
+ return ErrInvalidPath
+ }
+
+ if !strings.HasSuffix(path, ".md") {
+ return ErrInvalidPath
+ }
+
+ if strings.Contains(path, "..") {
+ return ErrInvalidPath
+ }
+
+ if !filepath.IsAbs(path) && strings.HasPrefix(path, "/") {
+ return ErrInvalidPath
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/internal/domain/tag.go b/internal/domain/tag.go
new file mode 100644
index 0000000..5ba39ec
--- /dev/null
+++ b/internal/domain/tag.go
@@ -0,0 +1,79 @@
+package domain
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Tag struct {
+ name string
+}
+
+func NewTag(name string) (*Tag, error) {
+ if name == "" {
+ return nil, fmt.Errorf("tag name cannot be empty")
+ }
+
+ if !strings.HasPrefix(name, "#") {
+ name = "#" + name
+ }
+
+ if !isValidTagName(name) {
+ return nil, ErrInvalidTag
+ }
+
+ return &Tag{name: name}, nil
+}
+
+func (t *Tag) Name() string {
+ return t.name
+}
+
+func (t *Tag) Parent() *Tag {
+ if !t.IsNested() {
+ return nil
+ }
+
+ lastSlash := strings.LastIndex(t.name, "/")
+ if lastSlash == -1 {
+ return nil
+ }
+
+ parentName := t.name[:lastSlash]
+ parent, _ := NewTag(parentName)
+ return parent
+}
+
+func (t *Tag) IsNested() bool {
+ return strings.Contains(t.name, "/")
+}
+
+func (t *Tag) String() string {
+ return t.name
+}
+
+func isValidTagName(name string) bool {
+ if !strings.HasPrefix(name, "#") {
+ return false
+ }
+
+ if len(name) <= 1 {
+ return false
+ }
+
+ tagContent := name[1:]
+
+ if strings.Contains(tagContent, " ") {
+ return false
+ }
+
+ if strings.HasPrefix(tagContent, "/") || strings.HasSuffix(tagContent, "/") {
+ return false
+ }
+
+ if strings.Contains(tagContent, "//") {
+ return false
+ }
+
+ return true
+}
\ No newline at end of file
diff --git a/internal/domain/vault.go b/internal/domain/vault.go
new file mode 100644
index 0000000..ed2f98c
--- /dev/null
+++ b/internal/domain/vault.go
@@ -0,0 +1,201 @@
+package domain
+
+import (
+ "sync"
+)
+
+type VaultStats struct {
+ TotalNotes int
+ TotalWords int
+ TotalLinks int
+ TotalTags int
+ AvgNoteLength int
+ OrphanedNotes int
+}
+
+type Vault struct {
+ rootPath string
+ notes map[string]*Note
+ graph *Graph
+ tags map[string]int
+ mutex sync.RWMutex
+}
+
+func NewVault(rootPath string) *Vault {
+ return &Vault{
+ rootPath: rootPath,
+ notes: make(map[string]*Note),
+ graph: NewGraph(),
+ tags: make(map[string]int),
+ }
+}
+
+func (v *Vault) RootPath() string {
+ return v.rootPath
+}
+
+func (v *Vault) AddNote(note *Note) error {
+ v.mutex.Lock()
+ defer v.mutex.Unlock()
+
+ if _, exists := v.notes[note.Path()]; exists {
+ return ErrNoteAlreadyExists
+ }
+
+ v.notes[note.Path()] = note
+
+ for _, link := range note.ExtractLinks() {
+ v.graph.AddEdge(note.Path(), link.Target())
+ }
+
+ for _, tag := range note.Tags() {
+ v.tags[tag.Name()]++
+ }
+
+ return nil
+}
+
+func (v *Vault) RemoveNote(path string) error {
+ v.mutex.Lock()
+ defer v.mutex.Unlock()
+
+ note, exists := v.notes[path]
+ if !exists {
+ return ErrNoteNotFound
+ }
+
+ for _, tag := range note.Tags() {
+ v.tags[tag.Name()]--
+ if v.tags[tag.Name()] <= 0 {
+ delete(v.tags, tag.Name())
+ }
+ }
+
+ v.graph.RemoveNode(path)
+ delete(v.notes, path)
+
+ return nil
+}
+
+func (v *Vault) GetNote(path string) (*Note, error) {
+ v.mutex.RLock()
+ defer v.mutex.RUnlock()
+
+ note, exists := v.notes[path]
+ if !exists {
+ return nil, ErrNoteNotFound
+ }
+
+ return note, nil
+}
+
+func (v *Vault) UpdateNote(note *Note) error {
+ v.mutex.Lock()
+ defer v.mutex.Unlock()
+
+ existingNote, exists := v.notes[note.Path()]
+ if !exists {
+ return ErrNoteNotFound
+ }
+
+ for _, tag := range existingNote.Tags() {
+ v.tags[tag.Name()]--
+ if v.tags[tag.Name()] <= 0 {
+ delete(v.tags, tag.Name())
+ }
+ }
+
+ v.graph.RemoveNode(note.Path())
+
+ v.notes[note.Path()] = note
+
+ for _, link := range note.ExtractLinks() {
+ v.graph.AddEdge(note.Path(), link.Target())
+ }
+
+ for _, tag := range note.Tags() {
+ v.tags[tag.Name()]++
+ }
+
+ return nil
+}
+
+func (v *Vault) ListNotes() []*Note {
+ v.mutex.RLock()
+ defer v.mutex.RUnlock()
+
+ notes := make([]*Note, 0, len(v.notes))
+ for _, note := range v.notes {
+ notes = append(notes, note)
+ }
+
+ return notes
+}
+
+func (v *Vault) GetStats() VaultStats {
+ v.mutex.RLock()
+ defer v.mutex.RUnlock()
+
+ totalWords := 0
+ totalLinks := 0
+
+ for _, note := range v.notes {
+ totalWords += note.WordCount()
+ totalLinks += len(note.ExtractLinks())
+ }
+
+ avgLength := 0
+ if len(v.notes) > 0 {
+ avgLength = totalWords / len(v.notes)
+ }
+
+ orphaned := v.countOrphanedNotes()
+
+ return VaultStats{
+ TotalNotes: len(v.notes),
+ TotalWords: totalWords,
+ TotalLinks: totalLinks,
+ TotalTags: len(v.tags),
+ AvgNoteLength: avgLength,
+ OrphanedNotes: orphaned,
+ }
+}
+
+func (v *Vault) GetBacklinks(path string) []string {
+ return v.graph.GetBacklinks(path)
+}
+
+func (v *Vault) GetOutlinks(path string) []string {
+ return v.graph.GetOutlinks(path)
+}
+
+func (v *Vault) GetAllTags() map[string]int {
+ v.mutex.RLock()
+ defer v.mutex.RUnlock()
+
+ tags := make(map[string]int)
+ for tag, count := range v.tags {
+ tags[tag] = count
+ }
+
+ return tags
+}
+
+func (v *Vault) countOrphanedNotes() int {
+ orphaned := 0
+ for path := range v.notes {
+ if len(v.graph.GetBacklinks(path)) == 0 && len(v.graph.GetOutlinks(path)) == 0 {
+ orphaned++
+ }
+ }
+ return orphaned
+}
+
+func (v *Vault) Clear() {
+ v.mutex.Lock()
+ defer v.mutex.Unlock()
+
+ v.notes = make(map[string]*Note)
+ v.graph.Clear()
+ v.tags = make(map[string]int)
+}
\ No newline at end of file
diff --git a/internal/domain/wikilink.go b/internal/domain/wikilink.go
new file mode 100644
index 0000000..a31b8b2
--- /dev/null
+++ b/internal/domain/wikilink.go
@@ -0,0 +1,78 @@
+package domain
+
+import (
+ "fmt"
+ "strings"
+)
+
+type WikiLink struct {
+ target string
+ alias string
+ section string
+}
+
+func NewWikiLink(target string, alias string, section string) (*WikiLink, error) {
+ if target == "" {
+ return nil, fmt.Errorf("wikilink target cannot be empty")
+ }
+
+ return &WikiLink{
+ target: target,
+ alias: alias,
+ section: section,
+ }, nil
+}
+
+func (w *WikiLink) Target() string {
+ return w.target
+}
+
+func (w *WikiLink) Alias() string {
+ return w.alias
+}
+
+func (w *WikiLink) Section() string {
+ return w.section
+}
+
+func (w *WikiLink) IsValid() bool {
+ return w.target != ""
+}
+
+func (w *WikiLink) String() string {
+ var parts []string
+
+ if w.alias != "" {
+ return fmt.Sprintf("[[%s|%s]]", w.target, w.alias)
+ }
+
+ if w.section != "" {
+ return fmt.Sprintf("[[%s#%s]]", w.target, w.section)
+ }
+
+ return fmt.Sprintf("[[%s]]", w.target)
+}
+
+func ParseWikiLink(linkText string) (*WikiLink, error) {
+ linkText = strings.Trim(linkText, "[]")
+
+ if linkText == "" {
+ return nil, fmt.Errorf("empty wikilink")
+ }
+
+ var target, alias, section string
+
+ if strings.Contains(linkText, "|") {
+ parts := strings.SplitN(linkText, "|", 2)
+ target = strings.TrimSpace(parts[0])
+ alias = strings.TrimSpace(parts[1])
+ } else if strings.Contains(linkText, "#") {
+ parts := strings.SplitN(linkText, "#", 2)
+ target = strings.TrimSpace(parts[0])
+ section = strings.TrimSpace(parts[1])
+ } else {
+ target = strings.TrimSpace(linkText)
+ }
+
+ return NewWikiLink(target, alias, section)
+}
\ No newline at end of file
diff --git a/internal/repository/git_repository.go b/internal/repository/git_repository.go
new file mode 100644
index 0000000..33d503d
--- /dev/null
+++ b/internal/repository/git_repository.go
@@ -0,0 +1,33 @@
+package repository
+
+import (
+ "context"
+ "time"
+)
+
+type GitStatus struct {
+ Branch string
+ Modified []string
+ Untracked []string
+ Staged []string
+ Ahead int
+ Behind int
+}
+
+type Commit struct {
+ Hash string
+ Author string
+ Date time.Time
+ Message string
+ Files []string
+}
+
+type GitRepository interface {
+ Status(ctx context.Context) (*GitStatus, error)
+ Pull(ctx context.Context) error
+ Push(ctx context.Context) error
+ Commit(ctx context.Context, message string, files []string) error
+ Log(ctx context.Context, path string, limit int) ([]*Commit, error)
+ IsEnabled() bool
+ Clone(ctx context.Context, url string, path string) error
+}
\ No newline at end of file
diff --git a/internal/repository/graph_index.go b/internal/repository/graph_index.go
new file mode 100644
index 0000000..afe6016
--- /dev/null
+++ b/internal/repository/graph_index.go
@@ -0,0 +1,40 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/user/obsidian-mcp-server/internal/domain"
+)
+
+type GraphNode struct {
+ Path string `json:"path"`
+ Title string `json:"title"`
+}
+
+type GraphEdge struct {
+ From string `json:"from"`
+ To string `json:"to"`
+}
+
+type GraphData struct {
+ Nodes []*GraphNode `json:"nodes"`
+ Edges []*GraphEdge `json:"edges"`
+}
+
+type BacklinkInfo struct {
+ From string `json:"from"`
+ Context string `json:"context"`
+ Line int `json:"line"`
+}
+
+type GraphIndex interface {
+ AddNote(ctx context.Context, note *domain.Note) error
+ RemoveNote(ctx context.Context, path string) error
+ GetBacklinks(ctx context.Context, path string) ([]*BacklinkInfo, error)
+ GetOutlinks(ctx context.Context, path string) ([]string, error)
+ GetConnected(ctx context.Context, path string, depth int) (*GraphData, error)
+ FindBrokenLinks(ctx context.Context, notePath string) ([]string, error)
+ UpdateLinks(ctx context.Context, oldPath, newPath string) error
+ Rebuild(ctx context.Context, notes []*domain.Note) error
+ Clear() error
+}
\ No newline at end of file
diff --git a/internal/repository/note_repository.go b/internal/repository/note_repository.go
new file mode 100644
index 0000000..8bf6f04
--- /dev/null
+++ b/internal/repository/note_repository.go
@@ -0,0 +1,18 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/user/obsidian-mcp-server/internal/domain"
+)
+
+type NoteRepository interface {
+ Get(ctx context.Context, path string) (*domain.Note, error)
+ Create(ctx context.Context, note *domain.Note) error
+ Update(ctx context.Context, note *domain.Note) error
+ Delete(ctx context.Context, path string) error
+ List(ctx context.Context, pattern string, recursive bool) ([]*domain.Note, error)
+ Exists(ctx context.Context, path string) bool
+ FindByTag(ctx context.Context, tag string) ([]*domain.Note, error)
+ FindByContent(ctx context.Context, query string) ([]*domain.Note, error)
+}
\ No newline at end of file
diff --git a/internal/repository/search_index.go b/internal/repository/search_index.go
new file mode 100644
index 0000000..3ef00d7
--- /dev/null
+++ b/internal/repository/search_index.go
@@ -0,0 +1,36 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/user/obsidian-mcp-server/internal/domain"
+)
+
+type SearchResult struct {
+ Path string `json:"path"`
+ Score float64 `json:"score"`
+ Matches []*SearchMatch `json:"matches"`
+}
+
+type SearchMatch struct {
+ Line int `json:"line"`
+ Content string `json:"content"`
+ ContextBefore string `json:"context_before"`
+ ContextAfter string `json:"context_after"`
+}
+
+type SearchQuery struct {
+ Query string
+ MaxResults int
+ CaseSensitive bool
+ UseRegex bool
+ ContextLines int
+}
+
+type SearchIndex interface {
+ Index(ctx context.Context, note *domain.Note) error
+ Remove(ctx context.Context, path string) error
+ Search(ctx context.Context, query *SearchQuery) ([]*SearchResult, error)
+ Rebuild(ctx context.Context, notes []*domain.Note) error
+ Clear() error
+}
\ No newline at end of file
diff --git a/internal/repository/tag_index.go b/internal/repository/tag_index.go
new file mode 100644
index 0000000..11aec1d
--- /dev/null
+++ b/internal/repository/tag_index.go
@@ -0,0 +1,30 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/user/obsidian-mcp-server/internal/domain"
+)
+
+type TagInfo struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+ Nested bool `json:"nested"`
+}
+
+type TagFilter struct {
+ Tags []string
+ Mode string // "all" or "any"
+ IncludeNested bool
+}
+
+type TagIndex interface {
+ AddNote(ctx context.Context, note *domain.Note) error
+ RemoveNote(ctx context.Context, path string) error
+ GetAllTags(ctx context.Context) ([]*TagInfo, error)
+ GetNotesWithTag(ctx context.Context, tag string) ([]string, error)
+ GetNotesWithTags(ctx context.Context, filter *TagFilter) ([]string, error)
+ RenameTag(ctx context.Context, oldTag, newTag string) error
+ Rebuild(ctx context.Context, notes []*domain.Note) error
+ Clear() error
+}
\ No newline at end of file
diff --git a/internal/repository/vault_repository.go b/internal/repository/vault_repository.go
new file mode 100644
index 0000000..f217c11
--- /dev/null
+++ b/internal/repository/vault_repository.go
@@ -0,0 +1,39 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/user/obsidian-mcp-server/internal/domain"
+)
+
+type VaultStructure struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Type string `json:"type"` // "file" or "directory"
+ Children []*VaultStructure `json:"children,omitempty"`
+}
+
+type MostLinkedNote struct {
+ Path string `json:"path"`
+ Count int `json:"count"`
+}
+
+type VaultStats struct {
+ TotalNotes int `json:"total_notes"`
+ TotalWords int `json:"total_words"`
+ TotalLinks int `json:"total_links"`
+ TotalTags int `json:"total_tags"`
+ AvgNoteLength int `json:"avg_note_length"`
+ MostLinked []*MostLinkedNote `json:"most_linked"`
+ OrphanedNotes int `json:"orphaned_notes"`
+ LastIndexed string `json:"last_indexed"`
+}
+
+type VaultRepository interface {
+ GetStats(ctx context.Context) (*VaultStats, error)
+ GetStructure(ctx context.Context, maxDepth int) (*VaultStructure, error)
+ FindOrphaned(ctx context.Context, excludePaths []string) ([]string, error)
+ GetMostLinked(ctx context.Context, limit int) ([]*MostLinkedNote, error)
+ Initialize(ctx context.Context, vault *domain.Vault) error
+ Rebuild(ctx context.Context) error
+}
\ No newline at end of file