mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
44 Commits
v0.13.8-dev1
...
v0.14.3
| Author | SHA1 | Date | |
|---|---|---|---|
| b0cc559426 | |||
| 7460a938df | |||
| 43fa5762a8 | |||
| fb1350b294 | |||
| 7585a29c96 | |||
| 752c78c379 | |||
| a4a3b1b689 | |||
| 6361574a20 | |||
| 24a1d6195d | |||
| a0cf62375d | |||
| 473f70c949 | |||
| 2c29dcc2b2 | |||
| 448210e552 | |||
| 3621bb7b4d | |||
| f80ac0ee72 | |||
| 23ddf1918c | |||
| 2aca19aa05 | |||
| 827f7cf3e3 | |||
| bd4f55158b | |||
| 5360005122 | |||
| 39f811f8b5 | |||
| 8e69c8b533 | |||
| 627a5c3c22 | |||
| cd88945b22 | |||
| cd8e372f0a | |||
| a589f8b894 | |||
| c2f4b632cf | |||
| 46d102cef1 | |||
| 8e4dc026ce | |||
| 7af8e198c2 | |||
| 12b51522bc | |||
| ac9e148bcc | |||
| 546e3cd8db | |||
| de4737cc22 | |||
| 77eefeb252 | |||
| e5923a0378 | |||
| 1bf348259b | |||
| 224e4bf9e4 | |||
| 9f1db23c78 | |||
| db5ef7d35c | |||
| f50650763d | |||
| 8a065c32f4 | |||
| 2a3adc109a | |||
| a52ce1c860 |
@@ -1,190 +0,0 @@
|
||||
# /project:check-health - Project Health Assessment
|
||||
|
||||
Comprehensive health check of the Basic Memory project including code quality, test coverage, dependencies, and documentation.
|
||||
|
||||
## Usage
|
||||
```
|
||||
/project:check-health
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
You are an expert DevOps engineer for the Basic Memory project. When the user runs `/project:check-health`, execute the following comprehensive assessment:
|
||||
|
||||
### Step 1: Git Repository Health
|
||||
1. **Repository Status**
|
||||
```bash
|
||||
git status
|
||||
git log --oneline -5
|
||||
git branch -vv
|
||||
```
|
||||
- Check working directory status
|
||||
- Verify branch alignment with remote
|
||||
- Check recent commit activity
|
||||
|
||||
2. **Branch Analysis**
|
||||
- Verify on main branch
|
||||
- Check if ahead/behind remote
|
||||
- Identify any untracked files
|
||||
|
||||
### Step 2: Code Quality Assessment
|
||||
1. **Linting and Formatting**
|
||||
```bash
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
```
|
||||
- Count linting issues by severity
|
||||
- Check type annotation coverage
|
||||
- Verify code formatting compliance
|
||||
|
||||
2. **Test Suite Health**
|
||||
```bash
|
||||
uv run pytest --collect-only -q
|
||||
uv run pytest --co -q | wc -l
|
||||
```
|
||||
- Count total tests
|
||||
- Check for test discovery issues
|
||||
- Verify test structure integrity
|
||||
|
||||
### Step 3: Dependency Analysis
|
||||
1. **Dependency Health**
|
||||
```bash
|
||||
uv tree
|
||||
uv lock --dry-run
|
||||
```
|
||||
- Check for dependency conflicts
|
||||
- Identify outdated dependencies
|
||||
- Verify lock file consistency
|
||||
|
||||
2. **Security Scan**
|
||||
```bash
|
||||
uv run pip-audit --desc
|
||||
```
|
||||
- Scan for known vulnerabilities
|
||||
- Check dependency licenses
|
||||
- Identify security advisories
|
||||
|
||||
### Step 4: Performance Metrics
|
||||
1. **Test Performance**
|
||||
```bash
|
||||
uv run pytest --durations=10
|
||||
```
|
||||
- Identify slowest tests
|
||||
- Check overall test execution time
|
||||
- Monitor test suite growth
|
||||
|
||||
2. **Build Performance**
|
||||
```bash
|
||||
time uv run python -c "import basic_memory"
|
||||
```
|
||||
- Check import time
|
||||
- Validate package installation
|
||||
- Monitor startup performance
|
||||
|
||||
### Step 5: Documentation Health
|
||||
1. **Documentation Coverage**
|
||||
- Check README.md currency
|
||||
- Verify CLI documentation
|
||||
- Validate MCP tool documentation
|
||||
- Check changelog completeness
|
||||
|
||||
2. **API Documentation**
|
||||
- Verify docstring coverage
|
||||
- Check type annotation completeness
|
||||
- Validate example code
|
||||
|
||||
### Step 6: Project Metrics
|
||||
1. **Code Statistics**
|
||||
```bash
|
||||
find src -name "*.py" | xargs wc -l
|
||||
find tests -name "*.py" | xargs wc -l
|
||||
```
|
||||
- Lines of code trends
|
||||
- Test-to-code ratio
|
||||
- File organization metrics
|
||||
|
||||
## Health Report Format
|
||||
|
||||
Generate comprehensive health dashboard:
|
||||
|
||||
```
|
||||
🏥 Basic Memory Project Health Report
|
||||
|
||||
📊 OVERALL HEALTH: 🟢 EXCELLENT (92/100)
|
||||
|
||||
🗂️ GIT REPOSITORY
|
||||
✅ Clean working directory
|
||||
✅ Up to date with origin/main
|
||||
✅ Recent commit activity (5 commits this week)
|
||||
|
||||
🔍 CODE QUALITY
|
||||
✅ Linting: 0 errors, 2 warnings
|
||||
✅ Type checking: 100% coverage
|
||||
✅ Formatting: Compliant
|
||||
⚠️ Complex functions: 3 need refactoring
|
||||
|
||||
🧪 TEST SUITE
|
||||
✅ Total tests: 744
|
||||
✅ Test discovery: All tests found
|
||||
✅ Coverage: 98.2%
|
||||
⚡ Performance: 45.2s (good)
|
||||
|
||||
📦 DEPENDENCIES
|
||||
✅ Dependencies: Up to date
|
||||
✅ Security: No vulnerabilities
|
||||
✅ Conflicts: None detected
|
||||
⚠️ Outdated: 2 minor updates available
|
||||
|
||||
📖 DOCUMENTATION
|
||||
✅ README: Current
|
||||
✅ API docs: 95% coverage
|
||||
⚠️ CLI reference: Needs update
|
||||
✅ Changelog: Complete
|
||||
|
||||
📈 METRICS
|
||||
├── Source code: 15,432 lines
|
||||
├── Test code: 8,967 lines
|
||||
├── Test ratio: 58% (excellent)
|
||||
└── Complexity: Low (maintainable)
|
||||
|
||||
🎯 RECOMMENDATIONS:
|
||||
1. Update CLI documentation
|
||||
2. Refactor 3 complex functions
|
||||
3. Update minor dependencies
|
||||
4. Consider splitting large test files
|
||||
|
||||
🏆 PROJECT STATUS: Ready for v0.13.0 release!
|
||||
```
|
||||
|
||||
## Health Scoring
|
||||
|
||||
### Excellent (90-100)
|
||||
- All quality gates pass
|
||||
- High test coverage (>95%)
|
||||
- No security issues
|
||||
- Documentation current
|
||||
|
||||
### Good (75-89)
|
||||
- Minor issues present
|
||||
- Good test coverage (>90%)
|
||||
- No critical security issues
|
||||
- Most documentation current
|
||||
|
||||
### Needs Attention (60-74)
|
||||
- Several quality issues
|
||||
- Adequate test coverage (>80%)
|
||||
- Minor security concerns
|
||||
- Documentation gaps
|
||||
|
||||
### Critical (<60)
|
||||
- Major quality problems
|
||||
- Low test coverage (<80%)
|
||||
- Security vulnerabilities
|
||||
- Significant documentation issues
|
||||
|
||||
## Context
|
||||
- Provides comprehensive project overview
|
||||
- Identifies potential issues before they become problems
|
||||
- Tracks project health trends over time
|
||||
- Helps prioritize maintenance tasks
|
||||
- Supports release readiness decisions
|
||||
@@ -1,62 +0,0 @@
|
||||
# Basic Memory Custom Commands
|
||||
|
||||
This directory contains custom Claude Code slash commands for the Basic Memory project.
|
||||
|
||||
## Available Commands
|
||||
|
||||
### Release Management (`/project:release:*`)
|
||||
- `/project:release:beta` - Create beta releases with automated quality checks
|
||||
- `/project:release:release` - Create stable releases with comprehensive validation
|
||||
- `/project:release:release-check` - Pre-flight validation without making changes
|
||||
- `/project:release:changelog` - Generate changelog entries from commits
|
||||
|
||||
### Development (`/project:*`)
|
||||
- `/project:test-coverage` - Run tests with detailed coverage analysis
|
||||
- `/project:test-live` - Live testing suite using real Basic Memory installation
|
||||
- `/project:lint-fix` - Run comprehensive linting with auto-fix
|
||||
- `/project:check-health` - Comprehensive project health assessment
|
||||
|
||||
## Command Structure
|
||||
|
||||
Commands are organized by functionality:
|
||||
```
|
||||
.claude/commands/
|
||||
├── release/ # Release management commands
|
||||
│ ├── beta.md # /project:release:beta
|
||||
│ ├── release.md # /project:release:release
|
||||
│ ├── release-check.md # /project:release:release-check
|
||||
│ └── changelog.md # /project:release:changelog
|
||||
├── test-coverage.md # /project:test-coverage
|
||||
├── test-live.md # /project:test-live
|
||||
├── lint-fix.md # /project:lint-fix
|
||||
├── check-health.md # /project:check-health
|
||||
└── commands.md # This overview file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Commands are invoked using the `/project:` prefix:
|
||||
- `/project:release:beta v0.13.0b4`
|
||||
- `/project:test-coverage mcp`
|
||||
- `/project:test-live core`
|
||||
- `/project:release:release-check`
|
||||
- `/project:check-health`
|
||||
|
||||
## Implementation
|
||||
|
||||
Each command is implemented as a Markdown file containing structured prompts that:
|
||||
1. Validate preconditions
|
||||
2. Execute steps in the correct order
|
||||
3. Handle errors gracefully
|
||||
4. Provide clear status updates
|
||||
5. Return actionable results
|
||||
|
||||
## Tooling Integration
|
||||
|
||||
Commands leverage existing project tooling:
|
||||
- `just check` - Quality checks
|
||||
- `just test` - Test suite
|
||||
- `just update-deps` - Dependency updates
|
||||
- `uv` - Package management
|
||||
- `git` - Version control
|
||||
- GitHub Actions - CI/CD pipeline
|
||||
@@ -1,145 +0,0 @@
|
||||
# /project:lint-fix - Comprehensive Code Quality Fix
|
||||
|
||||
Run comprehensive linting and auto-fix code quality issues across the codebase.
|
||||
|
||||
## Usage
|
||||
```
|
||||
/project:lint-fix
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
You are an expert code quality engineer for the Basic Memory project. When the user runs `/project:lint-fix`, execute the following steps:
|
||||
|
||||
### Step 1: Pre-flight Check
|
||||
1. **Verify Clean Working Directory**
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
- Check for uncommitted changes
|
||||
- Warn if working directory is not clean
|
||||
- Suggest stashing changes if needed
|
||||
|
||||
### Step 2: Import Organization
|
||||
1. **Fix Import Order and Cleanup**
|
||||
```bash
|
||||
uv run ruff check --select I --fix .
|
||||
```
|
||||
- Sort imports by category (standard, third-party, local)
|
||||
- Remove unused imports
|
||||
- Fix import spacing and organization
|
||||
|
||||
### Step 3: Code Formatting
|
||||
1. **Apply Consistent Formatting**
|
||||
```bash
|
||||
uv run ruff format .
|
||||
```
|
||||
- Format code according to project style
|
||||
- Fix line length issues (100 chars max)
|
||||
- Standardize quotes and spacing
|
||||
|
||||
### Step 4: Linting with Auto-fix
|
||||
1. **Fix Linting Issues**
|
||||
```bash
|
||||
uv run ruff check --fix .
|
||||
```
|
||||
- Auto-fix safe linting issues
|
||||
- Report any remaining manual fixes needed
|
||||
- Focus on code quality and best practices
|
||||
|
||||
### Step 5: Type Checking
|
||||
1. **Validate Type Annotations**
|
||||
```bash
|
||||
uv run pyright
|
||||
```
|
||||
- Check for type errors
|
||||
- Report any missing type annotations
|
||||
- Validate type compatibility
|
||||
|
||||
### Step 6: Report Generation
|
||||
Generate comprehensive quality report:
|
||||
|
||||
```
|
||||
🔧 Code Quality Fix Report
|
||||
|
||||
✅ FIXES APPLIED:
|
||||
├── Import organization: 12 files updated
|
||||
├── Code formatting: 8 files reformatted
|
||||
├── Auto-fixable lint issues: 23 issues resolved
|
||||
└── Total files processed: 156
|
||||
|
||||
⚠️ MANUAL ATTENTION NEEDED:
|
||||
├── Type annotations missing in entity_service.py:45
|
||||
├── Complex function needs refactoring in sync_service.py:123
|
||||
└── Unused variable in test_utils.py:67
|
||||
|
||||
🎯 QUALITY SCORE: 96.2% (excellent)
|
||||
|
||||
📁 Run `git diff` to review all changes
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
- **Merge Conflicts**: Provide resolution guidance
|
||||
- **Syntax Errors**: Point to specific files and lines
|
||||
- **Type Errors**: Suggest specific fixes
|
||||
- **Import Errors**: Check for missing dependencies
|
||||
|
||||
### Recovery Steps
|
||||
- If auto-fixes introduce issues, provide rollback instructions
|
||||
- If type checking fails, suggest incremental fixes
|
||||
- If tests break, provide debugging guidance
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### Must Pass
|
||||
- [ ] All auto-fixable lint issues resolved
|
||||
- [ ] Code formatting consistent
|
||||
- [ ] No syntax errors
|
||||
- [ ] Import organization clean
|
||||
|
||||
### Should Pass (Warnings)
|
||||
- [ ] No type checking errors
|
||||
- [ ] No complex function warnings
|
||||
- [ ] No unused variables/imports
|
||||
- [ ] Consistent naming conventions
|
||||
|
||||
## Output Examples
|
||||
|
||||
### Successful Fix
|
||||
```
|
||||
🎉 CODE QUALITY IMPROVED!
|
||||
|
||||
✅ All auto-fixes applied successfully
|
||||
📏 Code formatting: 100% compliant
|
||||
🔍 Linting: No issues found
|
||||
🏷️ Type checking: All passed
|
||||
|
||||
Ready for commit! Use:
|
||||
git add -A && git commit -m "style: fix code quality issues"
|
||||
```
|
||||
|
||||
### Issues Requiring Attention
|
||||
```
|
||||
⚠️ PARTIAL SUCCESS - MANUAL FIXES NEEDED
|
||||
|
||||
✅ Auto-fixes applied: 45 issues
|
||||
❌ Manual fixes needed: 3 issues
|
||||
|
||||
Priority fixes:
|
||||
1. Fix type annotation in services/entity_service.py:142
|
||||
2. Simplify complex function in sync/sync_service.py:67
|
||||
3. Remove unused import in tests/conftest.py:12
|
||||
|
||||
Run these commands:
|
||||
# Fix specific file
|
||||
uv run pyright src/basic_memory/services/entity_service.py
|
||||
```
|
||||
|
||||
## Context
|
||||
- Uses ruff for fast Python linting and formatting
|
||||
- Uses pyright for type checking
|
||||
- Follows project code style guidelines (100 char line length)
|
||||
- Maintains backward compatibility
|
||||
- Integrates with existing pre-commit hooks
|
||||
@@ -8,7 +8,7 @@ Analyze commits and generate formatted changelog entry for a version.
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `version` (required): Version like `v0.13.0` or `v0.13.0b4`
|
||||
- `version` (required): Version like `v0.14.0` or `v0.14.0b1`
|
||||
- `type` (optional): `beta`, `rc`, or `stable` (default: `stable`)
|
||||
|
||||
## Implementation
|
||||
@@ -59,8 +59,9 @@ You are an expert technical writer for the Basic Memory project. When the user r
|
||||
### Step 3: Generate Changelog Entry
|
||||
Create formatted entry following existing CHANGELOG.md style:
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
## v0.13.0 (2025-06-03)
|
||||
## <version> (<date>)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -128,6 +129,8 @@ Create formatted entry following existing CHANGELOG.md style:
|
||||
## Output Format
|
||||
|
||||
### For Beta Releases
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
## v0.13.0b4 (2025-06-03)
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# /test-coverage - Run Tests with Coverage Analysis
|
||||
|
||||
Execute test suite with comprehensive coverage reporting and analysis.
|
||||
|
||||
## Usage
|
||||
```
|
||||
/test-coverage [pattern]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `pattern` (optional): Test pattern to run specific tests (e.g., `test_mcp`, `*integration*`)
|
||||
|
||||
## Implementation
|
||||
|
||||
You are an expert QA engineer for the Basic Memory project. When the user runs `/test-coverage`, execute the following steps:
|
||||
|
||||
### Step 1: Test Execution
|
||||
1. **Run Tests with Coverage**
|
||||
```bash
|
||||
# Full test suite
|
||||
uv run pytest --cov=basic_memory --cov-report=html --cov-report=term -v
|
||||
|
||||
# Or with pattern if provided
|
||||
uv run pytest tests/*{pattern}* --cov=basic_memory --cov-report=html --cov-report=term -v
|
||||
```
|
||||
|
||||
2. **Generate Coverage Reports**
|
||||
- Terminal summary with percentages
|
||||
- HTML report for detailed analysis
|
||||
- Identify files below coverage threshold
|
||||
|
||||
### Step 2: Coverage Analysis
|
||||
1. **Summary Statistics**
|
||||
- Overall coverage percentage
|
||||
- Number of files with 100% coverage
|
||||
- Files below 95% threshold
|
||||
- Total lines covered/missed
|
||||
|
||||
2. **Detailed Breakdown**
|
||||
- Coverage by module/package
|
||||
- Identify untested code paths
|
||||
- Find missing edge case tests
|
||||
|
||||
### Step 3: Report Generation
|
||||
Generate comprehensive coverage report:
|
||||
|
||||
```
|
||||
🧪 Test Coverage Report
|
||||
|
||||
📊 OVERALL COVERAGE: 98.2% (target: 95%+)
|
||||
|
||||
✅ EXCELLENT COVERAGE (>95%):
|
||||
├── basic_memory/mcp/: 99.1%
|
||||
├── basic_memory/services/: 98.8%
|
||||
├── basic_memory/repository/: 97.9%
|
||||
└── basic_memory/api/: 96.2%
|
||||
|
||||
⚠️ NEEDS ATTENTION (<95%):
|
||||
├── basic_memory/sync/: 94.1% (missing 12 lines)
|
||||
└── basic_memory/importers/: 91.8% (missing 23 lines)
|
||||
|
||||
🎯 SPECIFIC GAPS:
|
||||
├── sync_service.py:142-145 (error handling)
|
||||
├── importer_base.py:67-70 (edge case)
|
||||
└── file_utils.py:89 (exception path)
|
||||
|
||||
📁 HTML Report: htmlcov/index.html
|
||||
🚀 Run `open htmlcov/index.html` to view detailed report
|
||||
```
|
||||
|
||||
### Step 4: Actionable Recommendations
|
||||
1. **Coverage Improvements**
|
||||
- Suggest specific tests to add
|
||||
- Identify edge cases to cover
|
||||
- Recommend integration tests
|
||||
|
||||
2. **Quality Insights**
|
||||
- Highlight well-tested modules
|
||||
- Point out testing patterns to follow
|
||||
- Suggest refactoring for testability
|
||||
|
||||
## Advanced Analysis
|
||||
|
||||
### Performance Metrics
|
||||
- Test execution time by module
|
||||
- Slowest tests identification
|
||||
- Coverage collection overhead
|
||||
|
||||
### Integration Coverage
|
||||
- MCP tool integration tests
|
||||
- API endpoint coverage
|
||||
- Database operation coverage
|
||||
- File system operation coverage
|
||||
|
||||
## Output Examples
|
||||
|
||||
### Full Coverage Success
|
||||
```
|
||||
🎉 EXCELLENT COVERAGE!
|
||||
|
||||
📊 Coverage: 98.7% (744 tests passed)
|
||||
✅ All modules above 95% threshold
|
||||
🏆 23 files with 100% coverage
|
||||
⚡ Tests completed in 45.2s
|
||||
|
||||
Ready for release! 🚀
|
||||
```
|
||||
|
||||
### Coverage Issues Found
|
||||
```
|
||||
⚠️ COVERAGE GAPS DETECTED
|
||||
|
||||
📊 Coverage: 92.1% (below 95% target)
|
||||
❌ 3 modules need attention
|
||||
🔍 43 uncovered lines found
|
||||
|
||||
Priority fixes:
|
||||
1. Add tests for error handling in sync_service.py
|
||||
2. Cover edge cases in importer_base.py
|
||||
3. Test exception paths in file_utils.py
|
||||
|
||||
Run specific tests:
|
||||
uv run pytest tests/sync/ -v
|
||||
```
|
||||
|
||||
## Context
|
||||
- Uses pytest with coverage plugin
|
||||
- Generates both terminal and HTML reports
|
||||
- Focuses on actionable improvement suggestions
|
||||
- Integrates with existing test infrastructure
|
||||
- Helps maintain high code quality standards
|
||||
+265
-80
@@ -1,6 +1,7 @@
|
||||
# /project:test-live - Live Basic Memory Testing Suite
|
||||
|
||||
Execute comprehensive real-world testing of Basic Memory using the installed version, following the methodology in TESTING.md. All test results are recorded as notes in a dedicated test project.
|
||||
Execute comprehensive real-world testing of Basic Memory using the installed version.
|
||||
All test results are recorded as notes in a dedicated test project.
|
||||
|
||||
## Usage
|
||||
```
|
||||
@@ -8,12 +9,45 @@ Execute comprehensive real-world testing of Basic Memory using the installed ver
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `phase` (optional): Specific test phase to run (`core`, `features`, `edge`, `workflows`, `stress`, or `all`)
|
||||
- `phase` (optional): Specific test phase to run (`recent`, `core`, `features`, `edge`, `workflows`, `stress`, or `all`)
|
||||
- `recent` - Focus on recent changes and new features (recommended for regular testing)
|
||||
- `core` - Essential tools only (Tier 1: write_note, read_note, search_notes, edit_note, list_projects, switch_project)
|
||||
- `features` - Core + important workflows (Tier 1 + Tier 2)
|
||||
- `all` - Comprehensive testing of all tools and scenarios
|
||||
|
||||
## Implementation
|
||||
|
||||
You are an expert QA engineer conducting live testing of Basic Memory.
|
||||
When the user runs `/project:test-live`, execute comprehensive testing following the TESTING.md methodology:
|
||||
When the user runs `/project:test-live`, execute comprehensive test plan:
|
||||
|
||||
## Tool Testing Priority
|
||||
|
||||
### **Tier 1: Critical Core (Always Test)**
|
||||
1. **write_note** - Foundation of all knowledge creation
|
||||
2. **read_note** - Primary knowledge retrieval mechanism
|
||||
3. **search_notes** - Essential for finding information
|
||||
4. **edit_note** - Core content modification capability
|
||||
5. **list_memory_projects** - Project discovery and status
|
||||
6. **switch_project** - Context switching for multi-project workflows
|
||||
|
||||
### **Tier 2: Important Workflows (Usually Test)**
|
||||
7. **recent_activity** - Understanding what's changed
|
||||
8. **build_context** - Conversation continuity via memory:// URLs
|
||||
9. **create_memory_project** - Essential for project setup
|
||||
10. **move_note** - Knowledge organization
|
||||
11. **sync_status** - Understanding system state
|
||||
|
||||
### **Tier 3: Enhanced Functionality (Sometimes Test)**
|
||||
12. **view_note** - Claude Desktop artifact display
|
||||
13. **read_content** - Raw content access
|
||||
14. **delete_note** - Content removal
|
||||
15. **list_directory** - File system exploration
|
||||
16. **set_default_project** - Configuration
|
||||
17. **delete_project** - Administrative cleanup
|
||||
|
||||
### **Tier 4: Specialized (Rarely Test)**
|
||||
18. **canvas** - Obsidian visualization (specialized use case)
|
||||
19. **MCP Prompts** - Enhanced UX tools (ai_assistant_guide, continue_conversation)
|
||||
|
||||
### Pre-Test Setup
|
||||
|
||||
@@ -22,7 +56,13 @@ When the user runs `/project:test-live`, execute comprehensive testing following
|
||||
- Check version and confirm it's the expected release
|
||||
- Test MCP connection and tool availability
|
||||
|
||||
2. **Test Project Creation**
|
||||
2. **Recent Changes Analysis** (if phase includes 'recent' or 'all')
|
||||
- Run `git log --oneline -20` to examine recent commits
|
||||
- Identify new features, bug fixes, and enhancements
|
||||
- Generate targeted test scenarios for recent changes
|
||||
- Prioritize regression testing for recently fixed issues
|
||||
|
||||
3. **Test Project Creation**
|
||||
|
||||
Run the bash `date` command to get the current date/time.
|
||||
|
||||
@@ -34,83 +74,158 @@ Run the bash `date` command to get the current date/time.
|
||||
|
||||
Make sure to switch to the newly created project with the `switch_project()` tool.
|
||||
|
||||
3. **Baseline Documentation**
|
||||
4. **Baseline Documentation**
|
||||
Create initial test session note with:
|
||||
- Test environment details
|
||||
- Version being tested
|
||||
- Recent changes identified (if applicable)
|
||||
- Test objectives and scope
|
||||
- Start timestamp
|
||||
|
||||
### Phase 1: Core Functionality Validation
|
||||
### Phase 0: Recent Changes Validation (if 'recent' or 'all' phase)
|
||||
|
||||
Test all fundamental MCP tools systematically:
|
||||
Based on recent commit analysis, create targeted test scenarios:
|
||||
|
||||
**write_note Tests:**
|
||||
- Basic note creation with various content types
|
||||
- Frontmatter handling (tags, custom fields)
|
||||
- Special characters in titles and content
|
||||
- Unicode and emoji support
|
||||
- Empty notes and minimal content
|
||||
**Recent Changes Test Protocol:**
|
||||
1. **Feature Addition Tests** - For each new feature identified:
|
||||
- Test basic functionality
|
||||
- Test integration with existing tools
|
||||
- Verify documentation accuracy
|
||||
- Test edge cases and error handling
|
||||
|
||||
**read_note Tests:**
|
||||
- Read by title, permalink, memory:// URLs
|
||||
- Non-existent notes (error handling)
|
||||
- Notes with complex formatting
|
||||
- Performance with large notes
|
||||
2. **Bug Fix Regression Tests** - For each recent fix:
|
||||
- Recreate the original problem scenario
|
||||
- Verify the fix works as expected
|
||||
- Test related functionality isn't broken
|
||||
- Document the verification in test notes
|
||||
|
||||
**view_note Tests:**
|
||||
- View notes as formatted artifacts (Claude Desktop)
|
||||
- Title extraction from frontmatter and headings
|
||||
- Unicode and emoji content in artifacts
|
||||
- Error handling for non-existent notes
|
||||
- Artifact display quality and readability
|
||||
3. **Performance/Enhancement Validation** - For optimizations:
|
||||
- Establish baseline timing
|
||||
- Compare with expected improvements
|
||||
- Test under various load conditions
|
||||
- Document performance observations
|
||||
|
||||
**search_notes Tests:**
|
||||
- Simple text queries
|
||||
- Tag-based searches
|
||||
- Boolean operators and complex queries
|
||||
- Empty/no results scenarios
|
||||
- Performance with growing knowledge base
|
||||
**Example Recent Changes (Update based on actual git log):**
|
||||
- Watch Service Restart (#156): Test project creation → file modification → automatic restart
|
||||
- Cross-Project Moves (#161): Test move_note with cross-project detection
|
||||
- Docker Environment Support (#174): Test BASIC_MEMORY_HOME behavior
|
||||
- MCP Server Logging (#164): Verify log level configurations
|
||||
|
||||
**Recent Activity Tests:**
|
||||
- Various timeframes ("today", "1 week", "1d")
|
||||
- Type filtering (if available)
|
||||
- Empty project scenarios
|
||||
- Performance with many recent changes
|
||||
### Phase 1: Core Functionality Validation (Tier 1 Tools)
|
||||
|
||||
**Context Building Tests:**
|
||||
- Different depth levels (1, 2, 3+)
|
||||
- Various timeframes
|
||||
- Relation traversal accuracy
|
||||
- Performance with complex graphs
|
||||
Test essential MCP tools that form the foundation of Basic Memory:
|
||||
|
||||
### Phase 2: v0.13.0 Feature Deep Dive
|
||||
**1. write_note Tests (Critical):**
|
||||
- ✅ Basic note creation with frontmatter
|
||||
- ✅ Special characters and Unicode in titles
|
||||
- ✅ Various content types (lists, headings, code blocks)
|
||||
- ✅ Empty notes and minimal content edge cases
|
||||
- ⚠️ Error handling for invalid parameters
|
||||
|
||||
**Project Management:**
|
||||
- Create multiple projects dynamically
|
||||
- Switch between projects mid-conversation
|
||||
- Cross-project operations
|
||||
- Project discovery and status
|
||||
- Default project behavior
|
||||
- Invalid project handling
|
||||
**2. read_note Tests (Critical):**
|
||||
- ✅ Read by title, permalink, memory:// URLs
|
||||
- ✅ Non-existent notes (error handling)
|
||||
- ✅ Notes with complex markdown formatting
|
||||
- ⚠️ Performance with large notes (>10MB)
|
||||
|
||||
**Advanced Note Editing:**
|
||||
- `edit_note` with append operations
|
||||
- Prepend operations
|
||||
- Find/replace with validation
|
||||
- Section replacement under headers
|
||||
- Error scenarios (invalid operations)
|
||||
- Frontmatter preservation
|
||||
**3. search_notes Tests (Critical):**
|
||||
- ✅ Simple text queries across content
|
||||
- ✅ Tag-based searches with multiple tags
|
||||
- ✅ Boolean operators (AND, OR, NOT)
|
||||
- ✅ Empty/no results scenarios
|
||||
- ⚠️ Performance with 100+ notes
|
||||
|
||||
**File Management:**
|
||||
- `move_note` within same project
|
||||
- Move between projects
|
||||
- Automatic folder creation
|
||||
- Special characters in paths
|
||||
- Database consistency validation
|
||||
- Search index updates after moves
|
||||
**4. edit_note Tests (Critical):**
|
||||
- ✅ Append operations preserving frontmatter
|
||||
- ✅ Prepend operations
|
||||
- ✅ Find/replace with validation
|
||||
- ✅ Section replacement under headers
|
||||
- ⚠️ Error scenarios (invalid operations)
|
||||
|
||||
### Phase 3: Edge Case Exploration
|
||||
**5. list_memory_projects Tests (Critical):**
|
||||
- ✅ Display all projects with status indicators
|
||||
- ✅ Current and default project identification
|
||||
- ✅ Empty project list handling
|
||||
- ✅ Project metadata accuracy
|
||||
|
||||
**6. switch_project Tests (Critical):**
|
||||
- ✅ Switch between existing projects
|
||||
- ✅ Context preservation during switch
|
||||
- ⚠️ Invalid project name handling
|
||||
- ✅ Confirmation of successful switch
|
||||
|
||||
### Phase 2: Important Workflows (Tier 2 Tools)
|
||||
|
||||
**7. recent_activity Tests (Important):**
|
||||
- ✅ Various timeframes ("today", "1 week", "1d")
|
||||
- ✅ Type filtering capabilities
|
||||
- ✅ Empty project scenarios
|
||||
- ⚠️ Performance with many recent changes
|
||||
|
||||
**8. build_context Tests (Important):**
|
||||
- ✅ Different depth levels (1, 2, 3+)
|
||||
- ✅ Various timeframes for context
|
||||
- ✅ memory:// URL navigation
|
||||
- ⚠️ Performance with complex relation graphs
|
||||
|
||||
**9. create_memory_project Tests (Important):**
|
||||
- ✅ Create projects dynamically
|
||||
- ✅ Set default during creation
|
||||
- ✅ Path validation and creation
|
||||
- ⚠️ Invalid paths and names
|
||||
- ✅ Integration with existing projects
|
||||
|
||||
**10. move_note Tests (Important):**
|
||||
- ✅ Move within same project
|
||||
- ✅ Cross-project moves with detection (#161)
|
||||
- ✅ Automatic folder creation
|
||||
- ✅ Database consistency validation
|
||||
- ⚠️ Special characters in paths
|
||||
|
||||
**11. sync_status Tests (Important):**
|
||||
- ✅ Background operation monitoring
|
||||
- ✅ File synchronization status
|
||||
- ✅ Project sync state reporting
|
||||
- ⚠️ Error state handling
|
||||
|
||||
### Phase 3: Enhanced Functionality (Tier 3 Tools)
|
||||
|
||||
**12. view_note Tests (Enhanced):**
|
||||
- ✅ Claude Desktop artifact display
|
||||
- ✅ Title extraction from frontmatter
|
||||
- ✅ Unicode and emoji content rendering
|
||||
- ⚠️ Error handling for non-existent notes
|
||||
|
||||
**13. read_content Tests (Enhanced):**
|
||||
- ✅ Raw file content access
|
||||
- ✅ Binary file handling
|
||||
- ✅ Image file reading
|
||||
- ⚠️ Large file performance
|
||||
|
||||
**14. delete_note Tests (Enhanced):**
|
||||
- ✅ Single note deletion
|
||||
- ✅ Database consistency after deletion
|
||||
- ⚠️ Non-existent note handling
|
||||
- ✅ Confirmation of successful deletion
|
||||
|
||||
**15. list_directory Tests (Enhanced):**
|
||||
- ✅ Directory content listing
|
||||
- ✅ Depth control and filtering
|
||||
- ✅ File name globbing
|
||||
- ⚠️ Empty directory handling
|
||||
|
||||
**16. set_default_project Tests (Enhanced):**
|
||||
- ✅ Change default project
|
||||
- ✅ Configuration persistence
|
||||
- ⚠️ Invalid project handling
|
||||
|
||||
**17. delete_project Tests (Enhanced):**
|
||||
- ✅ Project removal from config
|
||||
- ✅ Database cleanup
|
||||
- ⚠️ Default project protection
|
||||
- ⚠️ Non-existent project handling
|
||||
|
||||
### Phase 4: Edge Case Exploration
|
||||
|
||||
**Boundary Testing:**
|
||||
- Very long titles and content (stress limits)
|
||||
@@ -134,7 +249,7 @@ Test all fundamental MCP tools systematically:
|
||||
- Rapid successive operations
|
||||
- Memory usage monitoring
|
||||
|
||||
### Phase 4: Real-World Workflow Scenarios
|
||||
### Phase 5: Real-World Workflow Scenarios
|
||||
|
||||
**Meeting Notes Pipeline:**
|
||||
1. Create meeting notes with action items
|
||||
@@ -164,7 +279,35 @@ Test all fundamental MCP tools systematically:
|
||||
4. Update content with edit operations
|
||||
5. Validate knowledge graph integrity
|
||||
|
||||
### Phase 5: Creative Stress Testing
|
||||
### Phase 6: Specialized Tools Testing (Tier 4)
|
||||
|
||||
**18. canvas Tests (Specialized):**
|
||||
- ✅ JSON Canvas generation
|
||||
- ✅ Node and edge creation
|
||||
- ✅ Obsidian compatibility
|
||||
- ⚠️ Complex graph handling
|
||||
|
||||
**19. MCP Prompts Tests (Specialized):**
|
||||
- ✅ ai_assistant_guide output
|
||||
- ✅ continue_conversation functionality
|
||||
- ✅ Formatted search results
|
||||
- ✅ Enhanced activity reports
|
||||
|
||||
### Phase 7: Integration & File Watching Tests
|
||||
|
||||
**File System Integration:**
|
||||
- ✅ Watch service behavior with file changes
|
||||
- ✅ Project creation → watch restart (#156)
|
||||
- ✅ Multi-project synchronization
|
||||
- ⚠️ MCP→API→DB→File stack validation
|
||||
|
||||
**Real Integration Testing:**
|
||||
- ✅ End-to-end file watching vs manual operations
|
||||
- ✅ Cross-session persistence
|
||||
- ✅ Database consistency across operations
|
||||
- ⚠️ Performance under real file system changes
|
||||
|
||||
### Phase 8: Creative Stress Testing
|
||||
|
||||
**Creative Exploration:**
|
||||
- Rapid project creation/switching patterns
|
||||
@@ -180,6 +323,26 @@ Test all fundamental MCP tools systematically:
|
||||
- Complex boolean search expressions
|
||||
- Resource constraint testing
|
||||
|
||||
## Test Execution Guidelines
|
||||
|
||||
### Quick Testing (core/features phases)
|
||||
- Focus on Tier 1 tools (core) or Tier 1+2 (features)
|
||||
- Test essential functionality and common edge cases
|
||||
- Record critical issues immediately
|
||||
- Complete in 15-20 minutes
|
||||
|
||||
### Comprehensive Testing (all phase)
|
||||
- Cover all tiers systematically
|
||||
- Include specialized tools and stress testing
|
||||
- Document performance baselines
|
||||
- Complete in 45-60 minutes
|
||||
|
||||
### Recent Changes Focus (recent phase)
|
||||
- Analyze git log for recent commits
|
||||
- Generate targeted test scenarios
|
||||
- Focus on regression testing for fixes
|
||||
- Validate new features thoroughly
|
||||
|
||||
## Test Observation Format
|
||||
|
||||
Record ALL observations immediately as Basic Memory notes:
|
||||
@@ -202,14 +365,14 @@ permalink: test-session-[phase]-[timestamp]
|
||||
## Test Results
|
||||
|
||||
### ✅ Successful Operations
|
||||
- [timestamp] write_note: Created note with emoji title 📝 #functionality
|
||||
- [timestamp] search_notes: Boolean query returned 23 results in 0.4s #performance
|
||||
- [timestamp] edit_note: Append operation preserved frontmatter #reliability
|
||||
- [timestamp] ✅ write_note: Created note with emoji title 📝 #tier1 #functionality
|
||||
- [timestamp] ✅ search_notes: Boolean query returned 23 results in 0.4s #tier1 #performance
|
||||
- [timestamp] ✅ edit_note: Append operation preserved frontmatter #tier1 #reliability
|
||||
|
||||
### ⚠️ Issues Discovered
|
||||
- [timestamp] move_note: Slow with deep folder paths (2.1s) #performance
|
||||
- [timestamp] search_notes: Unicode query returned unexpected results #bug
|
||||
- [timestamp] project switch: Context lost for memory:// URLs #issue
|
||||
- [timestamp] ⚠️ move_note: Slow with deep folder paths (2.1s) #tier2 #performance
|
||||
- [timestamp] 🚨 search_notes: Unicode query returned unexpected results #tier1 #bug #critical
|
||||
- [timestamp] ⚠️ build_context: Context lost for memory:// URLs #tier2 #issue
|
||||
|
||||
### 🚀 Enhancements Identified
|
||||
- edit_note could benefit from preview mode #ux-improvement
|
||||
@@ -353,24 +516,44 @@ For each error discovered:
|
||||
- Create comprehensive summary report
|
||||
- Generate development recommendations
|
||||
|
||||
## Testing Success Criteria
|
||||
|
||||
### Core Testing (Tier 1) - Must Pass
|
||||
- All 6 critical tools function correctly
|
||||
- No critical bugs in essential workflows
|
||||
- Acceptable performance for basic operations
|
||||
- Error handling works as expected
|
||||
|
||||
### Feature Testing (Tier 1+2) - Should Pass
|
||||
- All 11 core + important tools function
|
||||
- Workflow scenarios complete successfully
|
||||
- Performance meets baseline expectations
|
||||
- Integration points work correctly
|
||||
|
||||
### Comprehensive Testing (All Tiers) - Complete Coverage
|
||||
- All tools tested across all scenarios
|
||||
- Edge cases and stress testing completed
|
||||
- Performance baselines established
|
||||
- Full documentation of issues and enhancements
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
**System Validation:**
|
||||
- v0.13.0 feature verification in real usage
|
||||
- Edge case discovery beyond unit tests
|
||||
- Feature verification prioritized by tier importance
|
||||
- Recent changes validated for regression
|
||||
- Performance baseline establishment
|
||||
- Bug identification with reproduction cases
|
||||
- Bug identification with severity assessment
|
||||
|
||||
**Knowledge Base Creation:**
|
||||
- Comprehensive testing documentation
|
||||
- Prioritized testing documentation
|
||||
- Real usage examples for user guides
|
||||
- Edge case scenarios for future testing
|
||||
- Recent changes validation records
|
||||
- Performance insights for optimization
|
||||
|
||||
**Development Insights:**
|
||||
- Prioritized bug fix list
|
||||
- Tier-based bug priority list
|
||||
- Recent changes impact assessment
|
||||
- Enhancement ideas from real usage
|
||||
- Architecture validation results
|
||||
- User experience improvement areas
|
||||
|
||||
## Post-Test Deliverables
|
||||
@@ -402,9 +585,11 @@ For each error discovered:
|
||||
- Add performance benchmarks and targets
|
||||
|
||||
## Context
|
||||
- Uses installed basic-memory version (not development)
|
||||
- Uses real installed basic-memory version
|
||||
- Tests complete MCP→API→DB→File stack
|
||||
- Creates living documentation in Basic Memory itself
|
||||
- Follows integration over isolation philosophy
|
||||
- Prioritizes testing by tool importance and usage frequency
|
||||
- Adapts to recent development changes dynamically
|
||||
- Focuses on real usage patterns over checklist validation
|
||||
- Generates actionable insights for development team
|
||||
- Generates actionable insights prioritized by impact
|
||||
@@ -111,4 +111,4 @@ jobs:
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: Bash(uv run pytest),Bash(uv run ruff check . --fix),Bash(uv run ruff format .),Bash(uv run pyright),Bash(just test),Bash(just lint),Bash(just format),Bash(just type-check),Bash(just check),Read,Write,Edit,MultiEdit,Glob,Grep,LS
|
||||
allowed_tools: Bash(uv run pytest),Bash(uv run ruff check . --fix),Bash(uv run ruff format .),Bash(uv run pyright),Bash(just test),Bash(just lint),Bash(just format),Bash(just type-check),Bash(just check),Read,Write,Edit,MultiEdit,Glob,Grep,LS, mcp__web_search
|
||||
@@ -57,6 +57,8 @@ jobs:
|
||||
name: Update Homebrew Formula
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for stable releases (not dev, beta, or rc versions)
|
||||
if: ${{ !contains(github.ref_name, 'dev') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
@@ -80,3 +82,4 @@ jobs:
|
||||
env:
|
||||
# Personal Access Token with repo scope for homebrew-basic-memory repo
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/Users/phernandez/dev/basicmachines/basic-memory",
|
||||
"run",
|
||||
"src/basic_memory/cli/main.py",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-40
@@ -1,56 +1,184 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.13.8 (2025-06-20)
|
||||
|
||||
### Features
|
||||
|
||||
- **Docker Container Support** - Complete Docker integration with volume mounting for Obsidian directories
|
||||
([`3269a2f`](https://github.com/basicmachines-co/basic-memory/commit/3269a2f33a7595f6d9e5207924062e2542f46759))
|
||||
- Docker Compose configuration for easy deployment
|
||||
- Volume mounting for persistent data and Obsidian integration
|
||||
- Comprehensive Docker documentation and setup guides
|
||||
- Streamlined container-based workflow
|
||||
## v0.14.2 (2025-07-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#151**: Fix reset command project configuration persistence issue
|
||||
([`af44941`](https://github.com/basicmachines-co/basic-memory/commit/af44941d5aa57b5ad7fcc6af4ed700f49bdb6d4d))
|
||||
- Reset command now properly clears project configuration from `~/.basic-memory/config.json`
|
||||
- Eliminates issue where projects would be recreated after database reset
|
||||
- Ensures clean slate when resetting Basic Memory installation
|
||||
- **#204**: Fix MCP Error with MCP-Hub integration
|
||||
([`3621bb7`](https://github.com/basicmachines-co/basic-memory/commit/3621bb7b4d6ac12d892b18e36bb8f7c9101c7b10))
|
||||
- Resolve compatibility issues with MCP-Hub
|
||||
- Improve error handling in project management tools
|
||||
- Ensure stable MCP tool integration across different environments
|
||||
|
||||
- **#148**: Resolve project state inconsistency between MCP and CLI
|
||||
([`35e4f73`](https://github.com/basicmachines-co/basic-memory/commit/35e4f73ae8a65501da4d48258ed702f957184c92))
|
||||
- Fix "Project not found" errors when switching default projects
|
||||
- MCP session now automatically refreshes when project configuration changes
|
||||
- Eliminates need to restart MCP server after project operations
|
||||
- Ensures consistent project state across CLI and MCP interfaces
|
||||
- **Modernize datetime handling and suppress SQLAlchemy warnings**
|
||||
([`f80ac0e`](https://github.com/basicmachines-co/basic-memory/commit/f80ac0e3e74b7a737a7fc7b956b5c1d61b0c67b8))
|
||||
- Replace deprecated `datetime.utcnow()` with timezone-aware alternatives
|
||||
- Suppress SQLAlchemy deprecation warnings for cleaner output
|
||||
- Improve future compatibility with Python datetime best practices
|
||||
|
||||
- **FastMCP Compatibility** - Resolve deprecation warnings for FastMCP integration
|
||||
([`7be001c`](https://github.com/basicmachines-co/basic-memory/commit/7be001ca6834b3344bb6160cbe537b36bcbaa579))
|
||||
- Update FastMCP usage patterns to eliminate deprecation warnings
|
||||
- Improve future compatibility with FastMCP library updates
|
||||
- Clean up entity repository and service layer code
|
||||
## v0.14.1 (2025-07-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#203**: Constrain fastmcp version to prevent breaking changes
|
||||
([`827f7cf`](https://github.com/basicmachines-co/basic-memory/commit/827f7cf86e7b84c56e7a43bb83f2e5d84a1ad8b8))
|
||||
- Pin fastmcp to compatible version range to avoid API breaking changes
|
||||
- Ensure stable MCP server functionality across updates
|
||||
- Improve dependency management for production deployments
|
||||
|
||||
- **#190**: Fix Problems with MCP integration
|
||||
([`bd4f551`](https://github.com/basicmachines-co/basic-memory/commit/bd4f551a5bb0b7b4d3a5b04de70e08987c6ab2f9))
|
||||
- Resolve MCP server initialization and communication issues
|
||||
- Improve error handling and recovery in MCP operations
|
||||
- Enhance stability for AI assistant integrations
|
||||
|
||||
### Features
|
||||
|
||||
- **Add Cursor IDE integration button** - One-click setup for Cursor IDE users
|
||||
([`5360005`](https://github.com/basicmachines-co/basic-memory/commit/536000512294d66090bf87abc8014f4dfc284310))
|
||||
- Direct installation button for Cursor IDE in README
|
||||
- Streamlined setup process for Cursor users
|
||||
- Enhanced developer experience for AI-powered coding
|
||||
|
||||
- **Add Homebrew installation instructions** - Official Homebrew tap support
|
||||
([`39f811f`](https://github.com/basicmachines-co/basic-memory/commit/39f811f8b57dd998445ae43537cd492c680b2e11))
|
||||
- Official Homebrew formula in basicmachines-co/basic-memory tap
|
||||
- Simplified installation process for macOS users
|
||||
- Package manager integration for easier dependency management
|
||||
|
||||
## v0.14.0 (2025-06-26)
|
||||
|
||||
### Features
|
||||
|
||||
- **Docker Container Registry Migration** - Switch from Docker Hub to GitHub Container Registry for better security and integration
|
||||
([`616c1f0`](https://github.com/basicmachines-co/basic-memory/commit/616c1f0710da59c7098a5f4843d4f017877ff7b2))
|
||||
- Automated Docker image publishing via GitHub Actions CI/CD pipeline
|
||||
- Enhanced container security with GitHub's integrated vulnerability scanning
|
||||
- Streamlined container deployment workflow for production environments
|
||||
|
||||
- **Enhanced Search Documentation** - Comprehensive search syntax examples for improved user experience
|
||||
([`a589f8b`](https://github.com/basicmachines-co/basic-memory/commit/a589f8b894e78cce01eb25656856cfea8785fbbf))
|
||||
- Detailed examples for Boolean search operators (AND, OR, NOT)
|
||||
- Advanced search patterns including phrase matching and field-specific queries
|
||||
- User-friendly documentation for complex search scenarios
|
||||
|
||||
- **Cross-Project File Management** - Intelligent move operations with project boundary detection
|
||||
([`db5ef7d`](https://github.com/basicmachines-co/basic-memory/commit/db5ef7d35cc0894309c7a57b5741c9dd978526d4))
|
||||
- Automatic detection of cross-project move attempts with helpful guidance
|
||||
- Clear error messages when attempting unsupported cross-project operations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#184**: Preserve permalinks when editing notes without frontmatter permalinks
|
||||
([`c2f4b63`](https://github.com/basicmachines-co/basic-memory/commit/c2f4b632cf04921b1a3c2f0d43831b80c519cb31))
|
||||
- Fix permalink preservation during note editing operations
|
||||
- Ensure consistent permalink handling across different note formats
|
||||
- Maintain note identity and searchability during incremental edits
|
||||
|
||||
- **#183**: Implement project-specific sync status checks for MCP tools
|
||||
([`12b5152`](https://github.com/basicmachines-co/basic-memory/commit/12b51522bc953fca117fc5bc01fcb29c6ca7e13c))
|
||||
- Fix sync status reporting to correctly reflect current project state
|
||||
- Resolve inconsistencies where sync status showed global instead of project-specific information
|
||||
- Improve project isolation for sync operations and status reporting
|
||||
|
||||
- **#180**: Handle Boolean search syntax with hyphenated terms
|
||||
([`546e3cd`](https://github.com/basicmachines-co/basic-memory/commit/546e3cd8db98b74f746749d41887f8a213cd0b11))
|
||||
- Fix search parsing issues with hyphenated terms in Boolean queries
|
||||
- Improve search query tokenization for complex term structures
|
||||
- Enhanced search reliability for technical documentation and multi-word concepts
|
||||
|
||||
- **#174**: Respect BASIC_MEMORY_HOME environment variable in Docker containers
|
||||
([`9f1db23`](https://github.com/basicmachines-co/basic-memory/commit/9f1db23c78d4648e2c242ad1ee27eed85e3f3b5d))
|
||||
- Fix Docker container configuration to properly honor custom home directory settings
|
||||
- Improve containerized deployment flexibility with environment variable support
|
||||
- Ensure consistent behavior between local and containerized installations
|
||||
|
||||
- **#168**: Scope entity queries by project_id in upsert_entity method
|
||||
([`2a3adc1`](https://github.com/basicmachines-co/basic-memory/commit/2a3adc109a3e4d7ccd65cae4abf63d9bb2338326))
|
||||
- Fix entity isolation issues in multi-project setups
|
||||
- Prevent cross-project entity conflicts during database operations
|
||||
- Strengthen project boundary enforcement at the database level
|
||||
|
||||
- **#166**: Handle None from_entity in Context API RelationSummary
|
||||
([`8a065c3`](https://github.com/basicmachines-co/basic-memory/commit/8a065c32f4e41613207d29aafc952a56e3a52241))
|
||||
- Fix null pointer exceptions in relation processing
|
||||
- Improve error handling for incomplete relation data
|
||||
- Enhanced stability for knowledge graph traversal operations
|
||||
|
||||
- **#164**: Remove log level configuration from mcp_server.run()
|
||||
([`224e4bf`](https://github.com/basicmachines-co/basic-memory/commit/224e4bf9e4438c44a82ffc21bd1a282fe9087690))
|
||||
- Simplify MCP server startup by removing redundant log level settings
|
||||
- Fix potential logging configuration conflicts
|
||||
- Streamline server initialization process
|
||||
|
||||
- **#162**: Ensure permalinks are generated for entities with null permalinks during move operations
|
||||
([`f506507`](https://github.com/basicmachines-co/basic-memory/commit/f50650763dbd4322c132e4bdc959ce4bf074374b))
|
||||
- Fix move operations for entities without existing permalinks
|
||||
- Automatic permalink generation during file move operations
|
||||
- Maintain database consistency during file reorganization
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
- **Comprehensive Integration Testing** - New test suites for critical user workflows
|
||||
- Full integration tests for database reset functionality
|
||||
- End-to-end project state synchronization testing
|
||||
- Real MCP client-server communication validation
|
||||
- Direct config file validation without complex mocking
|
||||
- **Comprehensive Test Coverage** - Extensive test suites for new features and edge cases
|
||||
- Enhanced test coverage for project-specific sync status functionality
|
||||
- Additional test scenarios for search syntax validation and edge cases
|
||||
- Integration tests for Docker CI workflow and container publishing
|
||||
- Comprehensive move operations testing with project boundary validation
|
||||
|
||||
- **Code Quality** - Enhanced error handling and validation
|
||||
- Improved project state management across system components
|
||||
- Better session refresh patterns for configuration changes
|
||||
- Streamlined Docker setup with reduced image size
|
||||
- **Docker CI/CD Pipeline** - Production-ready automated container publishing
|
||||
([`74847cc`](https://github.com/basicmachines-co/basic-memory/commit/74847cc3807b0c6ed511e0d83e0d560e9f07ec44))
|
||||
- Automated Docker image building and publishing on release
|
||||
- Multi-architecture container support for AMD64 and ARM64 platforms
|
||||
- Integrated security scanning and vulnerability assessments
|
||||
- Streamlined deployment pipeline for production environments
|
||||
|
||||
### Documentation
|
||||
- **Release Process Improvements** - Enhanced automation and quality gates
|
||||
([`a52ce1c`](https://github.com/basicmachines-co/basic-memory/commit/a52ce1c8605ec2cd450d1f909154172cbc30faa2))
|
||||
- Homebrew formula updates limited to stable releases only
|
||||
- Improved release automation with better quality control
|
||||
- Enhanced CI/CD pipeline reliability and error handling
|
||||
|
||||
- **Docker Integration Guide** - Complete documentation for container deployment
|
||||
- Step-by-step Docker Compose setup instructions
|
||||
- Volume mounting configuration for Obsidian workflows
|
||||
- Container-based development environment setup
|
||||
- **Code Quality Enhancements** - Improved error handling and validation
|
||||
- Better null safety in entity and relation processing
|
||||
- Enhanced project isolation validation throughout the codebase
|
||||
- Improved error messages and user guidance for edge cases
|
||||
- Strengthened database consistency guarantees across operations
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **GitHub Container Registry Integration** - Modern container infrastructure
|
||||
- Migration from Docker Hub to GitHub Container Registry (ghcr.io)
|
||||
- Improved security with integrated vulnerability scanning
|
||||
- Better integration with GitHub-based development workflow
|
||||
- Enhanced container versioning and artifact management
|
||||
|
||||
- **Enhanced CI/CD Workflows** - Robust automated testing and deployment
|
||||
- Automated Docker image publishing on releases
|
||||
- Comprehensive test coverage validation before deployment
|
||||
- Multi-platform container building and publishing
|
||||
- Integration with GitHub's security and monitoring tools
|
||||
|
||||
### Migration Guide
|
||||
|
||||
This release includes several behind-the-scenes improvements and fixes. All changes are backward compatible:
|
||||
|
||||
- **Docker Users**: Container images now served from `ghcr.io/basicmachines-co/basic-memory` instead of Docker Hub
|
||||
- **Search Users**: Enhanced search syntax handling - existing queries continue to work unchanged
|
||||
- **Multi-Project Users**: Improved project isolation - all existing projects remain fully functional
|
||||
- **All Users**: Enhanced stability and error handling - no breaking changes to existing workflows
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Latest stable release
|
||||
uv tool install basic-memory
|
||||
|
||||
# Update existing installation
|
||||
uv tool upgrade basic-memory
|
||||
|
||||
# Docker (new registry)
|
||||
docker pull ghcr.io/basicmachines-co/basic-memory:latest
|
||||
```
|
||||
|
||||
## v0.13.7 (2025-06-19)
|
||||
|
||||
|
||||
@@ -236,7 +236,9 @@ Basic Memory uses `uv-dynamic-versioning` for automatic version management based
|
||||
- Create version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
|
||||
#### Homebrew Formula Updates
|
||||
- Automatically triggered after successful PyPI release
|
||||
- Automatically triggered after successful PyPI release for **stable releases only**
|
||||
- **Stable releases** (e.g., v0.13.7) automatically update the main `basic-memory` formula
|
||||
- **Pre-releases** (dev/beta/rc) are NOT automatically updated - users must specify version manually
|
||||
- Updates formula in `basicmachines-co/homebrew-basic-memory` repo
|
||||
- Requires `HOMEBREW_TOKEN` secret in GitHub repository settings:
|
||||
- Create a fine-grained Personal Access Token with `Contents: Read and Write` and `Actions: Read` scopes on `basicmachines-co/homebrew-basic-memory`
|
||||
|
||||
+6
-1
@@ -27,7 +27,12 @@ project and how to get started as a developer.
|
||||
|
||||
> **Note**: Basic Memory uses [just](https://just.systems) as a modern command runner. Install with `brew install just` or `cargo install just`.
|
||||
|
||||
3. **Run the Tests**:
|
||||
3. **Activate the Virtual Environment**
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
4. **Run the Tests**:
|
||||
```bash
|
||||
# Run all tests
|
||||
just test
|
||||
|
||||
@@ -33,6 +33,10 @@ https://github.com/user-attachments/assets/a55d8238-8dd0-454a-be4c-8860dbbd0ddc
|
||||
# Install with uv (recommended)
|
||||
uv tool install basic-memory
|
||||
|
||||
# or with Homebrew
|
||||
brew tap basicmachines-co/basic-memory
|
||||
brew install basic-memory
|
||||
|
||||
# Configure Claude Desktop (edit ~/Library/Application Support/Claude/claude_desktop_config.json)
|
||||
# Add this to your config:
|
||||
{
|
||||
@@ -66,6 +70,13 @@ npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
|
||||
|
||||
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. Note: The Smithery installation uses their hosted MCP server, while your data remains stored locally as Markdown files.
|
||||
|
||||
### Add to Cursor
|
||||
|
||||
Once you have installed Basic Memory revisit this page for the 1-click installer for Cursor:
|
||||
|
||||
[](https://cursor.com/install-mcp?name=basic-memory&config=eyJjb21tYW5kIjoiL1VzZXJzL2RyZXcvLmxvY2FsL2Jpbi91dnggYmFzaWMtbWVtb3J5IG1jcCJ9)
|
||||
|
||||
|
||||
### Glama.ai
|
||||
|
||||
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
|
||||
@@ -214,7 +225,7 @@ title: <Entity title>
|
||||
type: <The type of Entity> (e.g. note)
|
||||
permalink: <a uri slug>
|
||||
|
||||
- <optional metadata> (such as tags)
|
||||
- <optional metadata> (such as tags)
|
||||
```
|
||||
|
||||
### Observations
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Character Handling and Conflict Resolution
|
||||
|
||||
Basic Memory handles various character encoding scenarios and file naming conventions to provide consistent permalink generation and conflict resolution. This document explains how the system works and how to resolve common character-related issues.
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory uses a sophisticated system to generate permalinks from file paths while maintaining consistency across different operating systems and character encodings. The system normalizes file paths and generates unique permalinks to prevent conflicts.
|
||||
|
||||
## Character Normalization Rules
|
||||
|
||||
### 1. Permalink Generation
|
||||
|
||||
When Basic Memory processes a file path, it applies these normalization rules:
|
||||
|
||||
```
|
||||
Original: "Finance/My Investment Strategy.md"
|
||||
Permalink: "finance/my-investment-strategy"
|
||||
```
|
||||
|
||||
**Transformation process:**
|
||||
1. Remove file extension (`.md`)
|
||||
2. Convert to lowercase (case-insensitive)
|
||||
3. Replace spaces with hyphens
|
||||
4. Replace underscores with hyphens
|
||||
5. Handle international characters (transliteration for Latin, preservation for non-Latin)
|
||||
6. Convert camelCase to kebab-case
|
||||
|
||||
### 2. International Character Support
|
||||
|
||||
**Latin characters with diacritics** are transliterated:
|
||||
- `ø` → `o` (Søren → soren)
|
||||
- `ü` → `u` (Müller → muller)
|
||||
- `é` → `e` (Café → cafe)
|
||||
- `ñ` → `n` (Niño → nino)
|
||||
|
||||
**Non-Latin characters** are preserved:
|
||||
- Chinese: `中文/测试文档.md` → `中文/测试文档`
|
||||
- Japanese: `日本語/文書.md` → `日本語/文書`
|
||||
|
||||
## Common Conflict Scenarios
|
||||
|
||||
### 1. Hyphen vs Space Conflicts
|
||||
|
||||
**Problem:** Files with existing hyphens conflict with generated permalinks from spaces.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
File 1: "basic memory bug.md" → permalink: "basic-memory-bug"
|
||||
File 2: "basic-memory-bug.md" → permalink: "basic-memory-bug" (CONFLICT!)
|
||||
```
|
||||
|
||||
**Resolution:** The system automatically resolves this by adding suffixes:
|
||||
```
|
||||
File 1: "basic memory bug.md" → permalink: "basic-memory-bug"
|
||||
File 2: "basic-memory-bug.md" → permalink: "basic-memory-bug-1"
|
||||
```
|
||||
|
||||
**Best Practice:** Choose consistent naming conventions within your project.
|
||||
|
||||
### 2. Case Sensitivity Conflicts
|
||||
|
||||
**Problem:** Different case variations that normalize to the same permalink.
|
||||
|
||||
**Example on macOS:**
|
||||
```
|
||||
Directory: Finance/investment.md
|
||||
Directory: finance/investment.md (different on filesystem, same permalink)
|
||||
```
|
||||
|
||||
**Resolution:** Basic Memory detects case conflicts and prevents them during sync operations with helpful error messages.
|
||||
|
||||
**Best Practice:** Use consistent casing for directory and file names.
|
||||
|
||||
### 3. Character Encoding Conflicts
|
||||
|
||||
**Problem:** Different Unicode normalizations of the same logical character.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
File 1: "café.md" (é as single character)
|
||||
File 2: "café.md" (e + combining accent)
|
||||
```
|
||||
|
||||
**Resolution:** Basic Memory normalizes Unicode characters using NFD normalization to detect these conflicts.
|
||||
|
||||
### 4. Forward Slash Conflicts
|
||||
|
||||
**Problem:** Forward slashes in frontmatter or file names interpreted as path separators.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
---
|
||||
permalink: finance/investment/strategy
|
||||
---
|
||||
```
|
||||
|
||||
**Resolution:** Basic Memory validates frontmatter permalinks and warns about path separator conflicts.
|
||||
|
||||
## Error Messages and Troubleshooting
|
||||
|
||||
### "UNIQUE constraint failed: entity.file_path, entity.project_id"
|
||||
|
||||
**Cause:** Two entities trying to use the same file path within a project.
|
||||
|
||||
**Common scenarios:**
|
||||
1. File move operation where destination is already occupied
|
||||
2. Case sensitivity differences on macOS
|
||||
3. Character encoding conflicts
|
||||
4. Concurrent file operations
|
||||
|
||||
**Resolution steps:**
|
||||
1. Check for duplicate file names with different cases
|
||||
2. Look for files with similar names but different character encodings
|
||||
3. Rename conflicting files to have unique names
|
||||
4. Run sync again after resolving conflicts
|
||||
|
||||
### "File path conflict detected during move"
|
||||
|
||||
**Cause:** Enhanced conflict detection preventing potential database integrity violations.
|
||||
|
||||
**What this means:** The system detected that moving a file would create a conflict before attempting the database operation.
|
||||
|
||||
**Resolution:** Follow the specific guidance in the error message, which will indicate the type of conflict detected.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. File Naming Conventions
|
||||
|
||||
**Recommended patterns:**
|
||||
- Use consistent casing (prefer lowercase)
|
||||
- Use hyphens instead of spaces for multi-word files
|
||||
- Avoid special characters that could conflict with path separators
|
||||
- Be consistent with directory structure casing
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
✅ Good:
|
||||
- finance/investment-strategy.md
|
||||
- projects/basic-memory-features.md
|
||||
- docs/api-reference.md
|
||||
|
||||
❌ Problematic:
|
||||
- Finance/Investment Strategy.md (mixed case, spaces)
|
||||
- finance/Investment Strategy.md (inconsistent case)
|
||||
- docs/API/Reference.md (mixed case directories)
|
||||
```
|
||||
|
||||
### 2. Permalink Management
|
||||
|
||||
**Custom permalinks in frontmatter:**
|
||||
```yaml
|
||||
---
|
||||
type: knowledge
|
||||
permalink: custom-permalink-name
|
||||
---
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Use lowercase permalinks
|
||||
- Use hyphens for word separation
|
||||
- Avoid path separators unless creating sub-paths
|
||||
- Ensure uniqueness within your project
|
||||
|
||||
### 3. Directory Structure
|
||||
|
||||
**Consistent casing:**
|
||||
```
|
||||
✅ Good:
|
||||
finance/
|
||||
investment-strategies.md
|
||||
portfolio-management.md
|
||||
|
||||
❌ Problematic:
|
||||
Finance/ (capital F)
|
||||
investment-strategies.md
|
||||
finance/ (lowercase f)
|
||||
portfolio-management.md
|
||||
```
|
||||
|
||||
## Migration and Cleanup
|
||||
|
||||
### Identifying Conflicts
|
||||
|
||||
Use Basic Memory's built-in conflict detection:
|
||||
|
||||
```bash
|
||||
# Sync will report conflicts
|
||||
basic-memory sync
|
||||
|
||||
# Check sync status for warnings
|
||||
basic-memory status
|
||||
```
|
||||
|
||||
### Resolving Existing Conflicts
|
||||
|
||||
1. **Identify conflicting files** from sync error messages
|
||||
2. **Choose consistent naming convention** for your project
|
||||
3. **Rename files** to follow the convention
|
||||
4. **Re-run sync** to verify resolution
|
||||
|
||||
### Bulk Renaming Strategy
|
||||
|
||||
For projects with many conflicts:
|
||||
|
||||
1. **Backup your project** before making changes
|
||||
2. **Standardize on lowercase** file and directory names
|
||||
3. **Replace spaces with hyphens** in file names
|
||||
4. **Use consistent character encoding** (UTF-8)
|
||||
5. **Test sync after each batch** of changes
|
||||
|
||||
## System Enhancements
|
||||
|
||||
### Recent Improvements (v0.13+)
|
||||
|
||||
1. **Enhanced conflict detection** before database operations
|
||||
2. **Improved error messages** with specific resolution guidance
|
||||
3. **Character normalization utilities** for consistent handling
|
||||
4. **File swap detection** for complex move scenarios
|
||||
5. **Proactive conflict warnings** during permalink resolution
|
||||
|
||||
### Monitoring and Logging
|
||||
|
||||
The system now provides detailed logging for conflict resolution:
|
||||
|
||||
```
|
||||
DEBUG: Detected potential file path conflicts for 'Finance/Investment.md': ['finance/investment.md']
|
||||
WARNING: File path conflict detected during move: entity_id=123 trying to move from 'old.md' to 'new.md'
|
||||
```
|
||||
|
||||
These logs help identify and resolve conflicts before they cause sync failures.
|
||||
|
||||
## Support and Resources
|
||||
|
||||
If you encounter character-related conflicts not covered in this guide:
|
||||
|
||||
1. **Check the logs** for specific conflict details
|
||||
2. **Review error messages** for resolution guidance
|
||||
3. **Report issues** with examples of the conflicting files
|
||||
4. **Consider the file naming best practices** outlined above
|
||||
|
||||
The Basic Memory system is designed to handle most character conflicts automatically while providing clear guidance for manual resolution when needed.
|
||||
@@ -3,6 +3,9 @@
|
||||
# Install dependencies
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
uv sync
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
# Run unit tests in parallel
|
||||
test-unit:
|
||||
@@ -17,7 +20,7 @@ test: test-unit test-int
|
||||
|
||||
# Lint and fix code
|
||||
lint:
|
||||
ruff check . --fix
|
||||
uv run ruff check . --fix
|
||||
|
||||
# Type check code
|
||||
type-check:
|
||||
@@ -179,4 +182,4 @@ beta version:
|
||||
|
||||
# List all available recipes
|
||||
default:
|
||||
@just --list
|
||||
@just --list
|
||||
|
||||
+3
-3
@@ -30,7 +30,7 @@ dependencies = [
|
||||
"alembic>=1.14.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=2.3.4",
|
||||
"fastmcp==2.10.2",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
@@ -52,7 +52,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src", "tests"]
|
||||
addopts = "--cov=basic_memory --cov-report term-missing -ra -q"
|
||||
addopts = "--cov=basic_memory --cov-report term-missing"
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "strict"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
@@ -123,4 +123,4 @@ omit = [
|
||||
]
|
||||
|
||||
[tool.logfire]
|
||||
ignore_no_config = true
|
||||
ignore_no_config = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.13.7"
|
||||
__version__ = "0.14.3"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -8,17 +8,19 @@ from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
|
||||
# Import after setting environment variable # noqa: E402
|
||||
from basic_memory.config import app_config # noqa: E402
|
||||
from basic_memory.models import Base # noqa: E402
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
app_config = ConfigManager().config
|
||||
# Set the SQLAlchemy URL from our app config
|
||||
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
@@ -20,15 +20,18 @@ from basic_memory.api.routers import (
|
||||
search,
|
||||
prompt_router,
|
||||
)
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app, initialize_file_sync
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
|
||||
app_config = ConfigManager().config
|
||||
# Initialize app and database
|
||||
logger.info("Starting Basic Memory API")
|
||||
print(f"fastapi {app_config.projects}")
|
||||
await initialize_app(app_config)
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
|
||||
|
||||
router = APIRouter(prefix="/management", tags=["management"])
|
||||
@@ -41,6 +41,8 @@ async def start_watch_service(
|
||||
# Watch service is already running
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
# Create and start a new watch service
|
||||
logger.info("Starting watch service via management API")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Router for project management."""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Path, Body
|
||||
from typing import Optional
|
||||
|
||||
@@ -32,40 +33,47 @@ async def get_project_info(
|
||||
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
|
||||
async def update_project(
|
||||
project_service: ProjectServiceDep,
|
||||
project_name: str = Path(..., description="Name of the project to update"),
|
||||
path: Optional[str] = Body(None, description="New path for the project"),
|
||||
name: str = Path(..., description="Name of the project to update"),
|
||||
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information in configuration and database.
|
||||
|
||||
Args:
|
||||
project_name: The name of the project to update
|
||||
path: Optional new path for the project
|
||||
name: The name of the project to update
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project_info = ProjectItem(
|
||||
name=project_name,
|
||||
path=project_service.projects.get(project_name, ""),
|
||||
name=name,
|
||||
path=project_service.projects.get(name, ""),
|
||||
)
|
||||
|
||||
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
|
||||
if path:
|
||||
await project_service.move_project(name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_path = path if path else project_service.projects.get(project_name, "")
|
||||
updated_path = path if path else project_service.projects.get(name, "")
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{project_name}' updated successfully",
|
||||
message=f"Project '{name}' updated successfully",
|
||||
status="success",
|
||||
default=(project_name == project_service.default_project),
|
||||
default=(name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(name=project_name, path=updated_path),
|
||||
new_project=ProjectItem(name=name, path=updated_path),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ async def to_graph_context(
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_entity.title, # pyright: ignore
|
||||
from_entity=from_entity.title if from_entity else None,
|
||||
to_entity=to_entity.title if to_entity else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import get_project_config, ConfigManager
|
||||
from basic_memory.mcp.project_session import session
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ def version_callback(value: bool) -> None:
|
||||
"""Show version and exit."""
|
||||
if value: # pragma: no cover
|
||||
import basic_memory
|
||||
from basic_memory.config import config
|
||||
|
||||
config = get_project_config()
|
||||
typer.echo(f"Basic Memory version: {basic_memory.__version__}")
|
||||
typer.echo(f"Current project: {config.project}")
|
||||
typer.echo(f"Project path: {config.home}")
|
||||
@@ -44,9 +44,9 @@ def app_callback(
|
||||
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
ensure_initialization(app_config)
|
||||
|
||||
# Initialize MCP session with the specified project or default
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import auth, status, sync, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project
|
||||
|
||||
__all__ = [
|
||||
"auth",
|
||||
"status",
|
||||
"sync",
|
||||
"db",
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""OAuth management commands."""
|
||||
|
||||
import typer
|
||||
from typing import Optional
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
|
||||
|
||||
auth_app = typer.Typer(help="OAuth client management commands")
|
||||
app.add_typer(auth_app, name="auth")
|
||||
|
||||
|
||||
@auth_app.command()
|
||||
def register_client(
|
||||
client_id: Optional[str] = typer.Option(
|
||||
None, help="Client ID (auto-generated if not provided)"
|
||||
),
|
||||
client_secret: Optional[str] = typer.Option(
|
||||
None, help="Client secret (auto-generated if not provided)"
|
||||
),
|
||||
issuer_url: str = typer.Option("http://localhost:8000", help="OAuth issuer URL"),
|
||||
):
|
||||
"""Register a new OAuth client for Basic Memory MCP server."""
|
||||
|
||||
# Create provider instance
|
||||
provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
# Create client info with required redirect_uris
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=client_id or "", # Provider will generate if empty
|
||||
client_secret=client_secret or "", # Provider will generate if empty
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")], # Default redirect URI
|
||||
client_name="Basic Memory OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
# Register the client
|
||||
import asyncio
|
||||
|
||||
asyncio.run(provider.register_client(client_info))
|
||||
|
||||
typer.echo("Client registered successfully!")
|
||||
typer.echo(f"Client ID: {client_info.client_id}")
|
||||
typer.echo(f"Client Secret: {client_info.client_secret}")
|
||||
typer.echo("\nSave these credentials securely - the client secret cannot be retrieved later.")
|
||||
|
||||
|
||||
@auth_app.command()
|
||||
def test_auth(
|
||||
issuer_url: str = typer.Option("http://localhost:8000", help="OAuth issuer URL"),
|
||||
):
|
||||
"""Test OAuth authentication flow.
|
||||
|
||||
IMPORTANT: Use the same FASTMCP_AUTH_SECRET_KEY environment variable
|
||||
as your MCP server for tokens to validate correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
async def test_flow():
|
||||
# Create provider with same secret key as server
|
||||
provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
# Register a test client
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=secrets.token_urlsafe(16),
|
||||
client_secret=secrets.token_urlsafe(32),
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
await provider.register_client(client_info)
|
||||
typer.echo(f"Registered test client: {client_info.client_id}")
|
||||
|
||||
# Get the client
|
||||
client = await provider.get_client(client_info.client_id)
|
||||
if not client:
|
||||
typer.echo("Error: Client not found after registration", err=True)
|
||||
return
|
||||
|
||||
# Create authorization request
|
||||
auth_params = AuthorizationParams(
|
||||
state="test-state",
|
||||
scopes=["read", "write"],
|
||||
code_challenge="test-challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:8000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
# Get authorization URL
|
||||
auth_url = await provider.authorize(client, auth_params)
|
||||
typer.echo(f"Authorization URL: {auth_url}")
|
||||
|
||||
# Extract auth code from URL
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
parsed = urlparse(auth_url)
|
||||
params = parse_qs(parsed.query)
|
||||
auth_code = params.get("code", [None])[0]
|
||||
|
||||
if not auth_code:
|
||||
typer.echo("Error: No authorization code in URL", err=True)
|
||||
return
|
||||
|
||||
# Load the authorization code
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
if not code_obj:
|
||||
typer.echo("Error: Invalid authorization code", err=True)
|
||||
return
|
||||
|
||||
# Exchange for tokens
|
||||
token = await provider.exchange_authorization_code(client, code_obj)
|
||||
typer.echo(f"Access token: {token.access_token}")
|
||||
typer.echo(f"Refresh token: {token.refresh_token}")
|
||||
typer.echo(f"Expires in: {token.expires_in} seconds")
|
||||
|
||||
# Validate access token
|
||||
access_token_obj = await provider.load_access_token(token.access_token)
|
||||
if access_token_obj:
|
||||
typer.echo("Access token validated successfully!")
|
||||
typer.echo(f"Client ID: {access_token_obj.client_id}")
|
||||
typer.echo(f"Scopes: {access_token_obj.scopes}")
|
||||
else:
|
||||
typer.echo("Error: Invalid access token", err=True)
|
||||
|
||||
asyncio.run(test_flow())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
auth_app()
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import app_config, config_manager
|
||||
from basic_memory.config import ConfigManager, BasicMemoryConfig, save_basic_memory_config
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -18,6 +17,8 @@ def reset(
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
config_manager = ConfigManager()
|
||||
app_config = config_manager.config
|
||||
# Get database path
|
||||
db_path = app_config.app_database_path
|
||||
|
||||
@@ -27,9 +28,8 @@ def reset(
|
||||
logger.info(f"Database file deleted: {db_path}")
|
||||
|
||||
# Reset project configuration
|
||||
config_manager.config.projects = {"main": str(Path.home() / "basic-memory")}
|
||||
config_manager.config.default_project = "main"
|
||||
config_manager.save_config(config_manager.config)
|
||||
config = BasicMemoryConfig()
|
||||
save_basic_memory_config(config_manager.config_file, config)
|
||||
logger.info("Project configuration reset to default")
|
||||
|
||||
# Create a new empty database
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.importers import ChatGPTImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,7 @@ console = Console()
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
@@ -49,7 +50,7 @@ def import_chatgpt(
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
config = get_project_config()
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,7 @@ console = Console()
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
@@ -42,6 +43,7 @@ def import_claude(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
config = get_project_config()
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,7 @@ console = Console()
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
@@ -41,6 +42,7 @@ def import_projects(
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
config = get_project_config()
|
||||
try:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,7 @@ console = Console()
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
@@ -46,6 +47,7 @@ def memory_json(
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = get_project_config()
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
@@ -74,7 +76,8 @@ def memory_json(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {result.entities} entities\n"
|
||||
f"Added {result.relations} relations",
|
||||
f"Added {result.relations} relations\n"
|
||||
f"Skipped {result.skipped_entities} entities\n",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import typer
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# Import mcp instance
|
||||
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
@@ -34,26 +35,13 @@ def mcp(
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
"""
|
||||
|
||||
# Check if OAuth is enabled
|
||||
import os
|
||||
|
||||
auth_enabled = os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true"
|
||||
if auth_enabled:
|
||||
logger.info("OAuth authentication is ENABLED")
|
||||
logger.info(f"Issuer URL: {os.getenv('FASTMCP_AUTH_ISSUER_URL', 'http://localhost:8000')}")
|
||||
if os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES"):
|
||||
logger.info(f"Required scopes: {os.getenv('FASTMCP_AUTH_REQUIRED_SCOPES')}")
|
||||
else:
|
||||
logger.info("OAuth authentication is DISABLED")
|
||||
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import initialize_file_sync
|
||||
|
||||
# Start the MCP server with the specified transport
|
||||
|
||||
# Use unified thread-based sync approach for both transports
|
||||
import threading
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
def run_file_sync():
|
||||
"""Run file sync in a separate thread with its own event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
@@ -78,7 +66,6 @@ def mcp(
|
||||
if transport == "stdio":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
log_level="INFO",
|
||||
)
|
||||
elif transport == "streamable-http" or transport == "sse":
|
||||
mcp_server.run(
|
||||
|
||||
@@ -23,6 +23,7 @@ from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.project_info import ProjectStatusResponse
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.tools.utils import call_patch
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
console = Console()
|
||||
@@ -148,6 +149,46 @@ def synchronize_projects() -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("move")
|
||||
def move_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to move"),
|
||||
new_path: str = typer.Argument(..., help="New absolute path for the project"),
|
||||
) -> None:
|
||||
"""Move a project to a new location."""
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(new_path))
|
||||
|
||||
try:
|
||||
data = {"path": resolved_path}
|
||||
project_name = generate_permalink(name)
|
||||
|
||||
current_project = session.get_current_project()
|
||||
response = asyncio.run(
|
||||
call_patch(client, f"/{current_project}/project/{project_name}", json=data)
|
||||
)
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
# Show important file movement reminder
|
||||
console.print() # Empty line for spacing
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
|
||||
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
|
||||
f"[cyan]{resolved_path}[/cyan]\n\n"
|
||||
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
|
||||
title="⚠️ Manual File Movement Required",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error moving project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("info")
|
||||
def display_project_info(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
|
||||
@@ -12,7 +12,7 @@ from rich.tree import Tree
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.sync import get_sync_service
|
||||
from basic_memory.config import config, app_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.repository import ProjectRepository
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
|
||||
@@ -126,6 +126,9 @@ async def run_status(verbose: bool = False): # pragma: no cover
|
||||
"""Check sync status of files vs database."""
|
||||
# Check knowledge/ directory
|
||||
|
||||
app_config = ConfigManager().config
|
||||
config = get_project_config()
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from rich.tree import Tree
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.models import Project
|
||||
@@ -29,7 +29,6 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
from basic_memory.config import app_config
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -42,6 +41,8 @@ class ValidationIssue:
|
||||
|
||||
async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
||||
"""Get sync service instance with all dependencies."""
|
||||
|
||||
app_config = ConfigManager().config
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
@@ -96,6 +97,7 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V
|
||||
|
||||
def display_sync_summary(knowledge: SyncReport):
|
||||
"""Display a one-line summary of sync changes."""
|
||||
config = get_project_config()
|
||||
total_changes = knowledge.total
|
||||
project_name = config.project
|
||||
|
||||
@@ -124,6 +126,7 @@ def display_sync_summary(knowledge: SyncReport):
|
||||
|
||||
def display_detailed_sync_results(knowledge: SyncReport):
|
||||
"""Display detailed sync results with trees."""
|
||||
config = get_project_config()
|
||||
project_name = config.project
|
||||
|
||||
if knowledge.total == 0:
|
||||
@@ -158,6 +161,9 @@ def display_detailed_sync_results(knowledge: SyncReport):
|
||||
|
||||
async def run_sync(verbose: bool = False):
|
||||
"""Run sync operation."""
|
||||
app_config = ConfigManager().config
|
||||
config = get_project_config()
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
@@ -212,6 +218,8 @@ def sync(
|
||||
),
|
||||
) -> None:
|
||||
"""Sync knowledge files with the database."""
|
||||
config = get_project_config()
|
||||
|
||||
try:
|
||||
# Show which project we're syncing
|
||||
typer.echo(f"Syncing project: {config.project}")
|
||||
|
||||
@@ -4,7 +4,6 @@ from basic_memory.cli.app import app # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
auth,
|
||||
db,
|
||||
import_chatgpt,
|
||||
import_claude_conversations,
|
||||
|
||||
+52
-33
@@ -45,7 +45,9 @@ class BasicMemoryConfig(BaseSettings):
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
|
||||
default_factory=lambda: {
|
||||
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
|
||||
},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
)
|
||||
default_project: str = Field(
|
||||
@@ -72,6 +74,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Whether to sync changes in real time. default (True)",
|
||||
)
|
||||
|
||||
# API connection configuration
|
||||
api_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
@@ -92,7 +100,9 @@ class BasicMemoryConfig(BaseSettings):
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
# Ensure main project exists
|
||||
if "main" not in self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(Path.home() / "basic-memory")
|
||||
self.projects["main"] = str(
|
||||
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
||||
)
|
||||
|
||||
# Ensure default project is valid
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
@@ -120,6 +130,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
# Load the app-level database path from the global config
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config() # pragma: no cover
|
||||
return config.app_database_path # pragma: no cover
|
||||
|
||||
@@ -158,20 +169,21 @@ class ConfigManager:
|
||||
# Ensure config directory exists
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load or create configuration
|
||||
self.config = self.load_config()
|
||||
@property
|
||||
def config(self) -> BasicMemoryConfig:
|
||||
"""Get configuration, loading it lazily if needed."""
|
||||
return self.load_config()
|
||||
|
||||
def load_config(self) -> BasicMemoryConfig:
|
||||
"""Load configuration from file or create default."""
|
||||
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
||||
return BasicMemoryConfig(**data)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to load config: {e}")
|
||||
config = BasicMemoryConfig()
|
||||
self.save_config(config)
|
||||
return config
|
||||
logger.exception(f"Failed to load config: {e}")
|
||||
raise e
|
||||
else:
|
||||
config = BasicMemoryConfig()
|
||||
self.save_config(config)
|
||||
@@ -179,10 +191,7 @@ class ConfigManager:
|
||||
|
||||
def save_config(self, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
save_basic_memory_config(self.config_file, config)
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
@@ -204,8 +213,10 @@ class ConfigManager:
|
||||
project_path = Path(path)
|
||||
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
||||
|
||||
self.config.projects[name] = str(project_path)
|
||||
self.save_config(self.config)
|
||||
# Load config, modify it, and save it
|
||||
config = self.load_config()
|
||||
config.projects[name] = str(project_path)
|
||||
self.save_config(config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
def remove_project(self, name: str) -> None:
|
||||
@@ -215,11 +226,13 @@ class ConfigManager:
|
||||
if not project_name: # pragma: no cover
|
||||
raise ValueError(f"Project '{name}' not found")
|
||||
|
||||
if project_name == self.config.default_project: # pragma: no cover
|
||||
# Load config, check, modify, and save
|
||||
config = self.load_config()
|
||||
if project_name == config.default_project: # pragma: no cover
|
||||
raise ValueError(f"Cannot remove the default project '{name}'")
|
||||
|
||||
del self.config.projects[name]
|
||||
self.save_config(self.config)
|
||||
del config.projects[name]
|
||||
self.save_config(config)
|
||||
|
||||
def set_default_project(self, name: str) -> None:
|
||||
"""Set the default project."""
|
||||
@@ -227,15 +240,18 @@ class ConfigManager:
|
||||
if not project_name: # pragma: no cover
|
||||
raise ValueError(f"Project '{name}' not found")
|
||||
|
||||
self.config.default_project = name
|
||||
self.save_config(self.config)
|
||||
# Load config, modify, and save
|
||||
config = self.load_config()
|
||||
config.default_project = name
|
||||
self.save_config(config)
|
||||
|
||||
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
||||
"""Look up a project from the configuration by name or permalink"""
|
||||
project_permalink = generate_permalink(name)
|
||||
for name, path in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(name):
|
||||
return name, path
|
||||
app_config = self.config
|
||||
for project_name, path in app_config.projects.items():
|
||||
if project_permalink == generate_permalink(project_name):
|
||||
return project_name, path
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -248,7 +264,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
actual_project_name = None
|
||||
|
||||
# load the config from file
|
||||
global app_config
|
||||
config_manager = ConfigManager()
|
||||
app_config = config_manager.load_config()
|
||||
|
||||
# Get project name from environment variable
|
||||
@@ -278,14 +294,12 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
||||
|
||||
|
||||
# Create config manager
|
||||
config_manager = ConfigManager()
|
||||
|
||||
# Export the app-level config
|
||||
app_config: BasicMemoryConfig = config_manager.config
|
||||
|
||||
# Load project config for the default project (backward compatibility)
|
||||
config: ProjectConfig = get_project_config()
|
||||
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
file_path.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
|
||||
|
||||
def update_current_project(project_name: str) -> None:
|
||||
@@ -337,12 +351,17 @@ def setup_basic_memory_logging(): # pragma: no cover
|
||||
# print("Skipping duplicate logging setup")
|
||||
return
|
||||
|
||||
# Check for console logging environment variable
|
||||
console_logging = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower() == "true"
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = get_project_config()
|
||||
setup_logging(
|
||||
env=config_manager.config.env,
|
||||
home_dir=user_home, # Use user home for logs
|
||||
log_level=config_manager.load_config().log_level,
|
||||
log_level=config_manager.config.log_level,
|
||||
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
||||
console=False,
|
||||
console=console_logging,
|
||||
)
|
||||
|
||||
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -88,7 +88,6 @@ async def get_or_create_db(
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
||||
ensure_migrations: bool = True,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get or create database engine and session maker."""
|
||||
global _engine, _session_maker
|
||||
@@ -98,10 +97,7 @@ async def get_or_create_db(
|
||||
|
||||
# Run migrations automatically unless explicitly disabled
|
||||
if ensure_migrations:
|
||||
if app_config is None:
|
||||
from basic_memory.config import app_config as global_app_config
|
||||
|
||||
app_config = global_app_config
|
||||
app_config = ConfigManager().config
|
||||
await run_migrations(app_config, db_type)
|
||||
|
||||
# These checks should never fail since we just created the engine and session maker
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
import pathlib
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.importers import (
|
||||
ChatGPTImporter,
|
||||
ClaudeConversationsImporter,
|
||||
@@ -33,10 +33,10 @@ from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
from basic_memory.config import app_config
|
||||
|
||||
|
||||
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
||||
app_config = ConfigManager().config
|
||||
return app_config
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceDep,
|
||||
entity_parser: EntityParserDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
|
||||
@@ -193,7 +193,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
def _traverse_messages(
|
||||
self, mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
||||
) -> List[Dict[str, Any]]: # pragma: no cover
|
||||
"""Traverse message tree and return messages in order.
|
||||
"""Traverse message tree iteratively to handle deep conversations.
|
||||
|
||||
Args:
|
||||
mapping: Message mapping.
|
||||
@@ -204,19 +204,29 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
List of message data.
|
||||
"""
|
||||
messages = []
|
||||
node = mapping.get(root_id) if root_id else None
|
||||
if not root_id:
|
||||
return messages
|
||||
|
||||
while node:
|
||||
# Use iterative approach with stack to avoid recursion depth issues
|
||||
stack = [root_id]
|
||||
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
node = mapping.get(node_id)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
# Process current node if it has a message and hasn't been seen
|
||||
if node["id"] not in seen and node.get("message"):
|
||||
seen.add(node["id"])
|
||||
messages.append(node["message"])
|
||||
|
||||
# Follow children
|
||||
# Add children to stack in reverse order to maintain conversation flow
|
||||
children = node.get("children", [])
|
||||
for child_id in children:
|
||||
child_msgs = self._traverse_messages(mapping, child_id, seen)
|
||||
messages.extend(child_msgs)
|
||||
|
||||
break # Don't follow siblings
|
||||
for child_id in reversed(children):
|
||||
stack.append(child_id)
|
||||
|
||||
return messages
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import EntityImportResult
|
||||
@@ -27,10 +27,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
Returns:
|
||||
EntityImportResult containing statistics and status of the import.
|
||||
"""
|
||||
config = get_project_config()
|
||||
try:
|
||||
# First pass - collect all relations by source entity
|
||||
entity_relations: Dict[str, List[Relation]] = {}
|
||||
entities: Dict[str, Dict[str, Any]] = {}
|
||||
skipped_entities: int = 0
|
||||
|
||||
# Ensure the base path exists
|
||||
base_path = config.home # pragma: no cover
|
||||
@@ -41,7 +43,13 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
for line in source_data:
|
||||
data = line
|
||||
if data["type"] == "entity":
|
||||
entities[data["name"]] = data
|
||||
# Handle different possible name keys
|
||||
entity_name = data.get("name") or data.get("entityName") or data.get("id")
|
||||
if not entity_name:
|
||||
logger.warning(f"Entity missing name field: {data}")
|
||||
skipped_entities += 1
|
||||
continue
|
||||
entities[entity_name] = data
|
||||
elif data["type"] == "relation":
|
||||
# Store relation with its source entity
|
||||
source = data.get("from") or data.get("from_id")
|
||||
@@ -57,25 +65,31 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
# Second pass - create and write entities
|
||||
entities_created = 0
|
||||
for name, entity_data in entities.items():
|
||||
# Get entity type with fallback
|
||||
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
||||
|
||||
# Ensure entity type directory exists
|
||||
entity_type_dir = base_path / entity_data["entityType"]
|
||||
entity_type_dir = base_path / entity_type
|
||||
entity_type_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get observations with fallback to empty list
|
||||
observations = entity_data.get("observations", [])
|
||||
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": entity_data["entityType"],
|
||||
"type": entity_type,
|
||||
"title": name,
|
||||
"permalink": f"{entity_data['entityType']}/{name}",
|
||||
"permalink": f"{entity_type}/{name}",
|
||||
}
|
||||
),
|
||||
content=f"# {name}\n",
|
||||
observations=[Observation(content=obs) for obs in entity_data["observations"]],
|
||||
observations=[Observation(content=obs) for obs in observations],
|
||||
relations=entity_relations.get(name, []),
|
||||
)
|
||||
|
||||
# Write entity file
|
||||
file_path = base_path / f"{entity_data['entityType']}/{name}.md"
|
||||
file_path = base_path / f"{entity_type}/{name}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
entities_created += 1
|
||||
|
||||
@@ -86,6 +100,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
success=True,
|
||||
entities=entities_created,
|
||||
relations=relations_count,
|
||||
skipped_entities=skipped_entities,
|
||||
)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
|
||||
@@ -38,7 +38,9 @@ def entity_model_from_markdown(
|
||||
# Update basic fields
|
||||
model.title = markdown.frontmatter.title
|
||||
model.entity_type = markdown.frontmatter.type
|
||||
model.permalink = markdown.frontmatter.permalink
|
||||
# Only update permalink if it exists in frontmatter, otherwise preserve existing
|
||||
if markdown.frontmatter.permalink is not None:
|
||||
model.permalink = markdown.frontmatter.permalink
|
||||
model.file_path = str(file_path)
|
||||
model.content_type = "text/markdown"
|
||||
model.created_at = markdown.created
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
def create_client() -> AsyncClient:
|
||||
"""Create an HTTP client based on configuration.
|
||||
|
||||
Returns:
|
||||
AsyncClient configured for either local ASGI or remote HTTP transport
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
|
||||
if config.api_url:
|
||||
# Use HTTP transport for remote API
|
||||
logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
|
||||
return AsyncClient(base_url=config.api_url)
|
||||
else:
|
||||
# Use ASGI transport for local API
|
||||
logger.debug("Creating ASGI client for local Basic Memory API")
|
||||
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
|
||||
|
||||
BASE_URL = "http://test"
|
||||
|
||||
# Create shared async client
|
||||
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
|
||||
client = create_client()
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
"""OAuth authentication provider for Basic Memory MCP server."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jwt
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class BasicMemoryAuthorizationCode(AuthorizationCode):
|
||||
"""Extended authorization code with additional metadata."""
|
||||
|
||||
issuer_state: Optional[str] = None
|
||||
|
||||
|
||||
class BasicMemoryRefreshToken(RefreshToken):
|
||||
"""Extended refresh token with additional metadata."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BasicMemoryAccessToken(AccessToken):
|
||||
"""Extended access token with additional metadata."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BasicMemoryOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
BasicMemoryAuthorizationCode, BasicMemoryRefreshToken, BasicMemoryAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider for Basic Memory MCP server.
|
||||
|
||||
This is a simple in-memory implementation that can be extended
|
||||
to integrate with external OAuth providers or use persistent storage.
|
||||
"""
|
||||
|
||||
def __init__(self, issuer_url: str = "http://localhost:8000", secret_key: Optional[str] = None):
|
||||
self.issuer_url = issuer_url
|
||||
# Use environment variable for secret key if available, otherwise generate
|
||||
import os
|
||||
|
||||
self.secret_key = (
|
||||
secret_key or os.getenv("FASTMCP_AUTH_SECRET_KEY") or secrets.token_urlsafe(32)
|
||||
)
|
||||
|
||||
# In-memory storage - in production, use a proper database
|
||||
self.clients: Dict[str, OAuthClientInformationFull] = {}
|
||||
self.authorization_codes: Dict[str, BasicMemoryAuthorizationCode] = {}
|
||||
self.refresh_tokens: Dict[str, BasicMemoryRefreshToken] = {}
|
||||
self.access_tokens: Dict[str, BasicMemoryAccessToken] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client by ID."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client."""
|
||||
# Generate client ID if not provided
|
||||
if not client_info.client_id:
|
||||
client_info.client_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Generate client secret if not provided
|
||||
if not client_info.client_secret:
|
||||
client_info.client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
self.clients[client_info.client_id] = client_info
|
||||
logger.info(f"Registered OAuth client: {client_info.client_id}")
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create an authorization URL for the OAuth flow.
|
||||
|
||||
For basic-memory, we'll implement a simple authorization flow.
|
||||
In production, this might redirect to an external provider.
|
||||
"""
|
||||
# Generate authorization code
|
||||
auth_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Store authorization code with metadata
|
||||
self.authorization_codes[auth_code] = BasicMemoryAuthorizationCode(
|
||||
code=auth_code,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
issuer_state=params.state,
|
||||
)
|
||||
|
||||
# In a real implementation, we'd redirect to an authorization page
|
||||
# For now, we'll just return the redirect URL with the code
|
||||
redirect_uri = str(params.redirect_uri)
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
|
||||
auth_url = f"{redirect_uri}{separator}code={auth_code}"
|
||||
if params.state:
|
||||
auth_url += f"&state={params.state}"
|
||||
|
||||
return auth_url
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[BasicMemoryAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.authorization_codes.get(authorization_code)
|
||||
|
||||
if code and code.client_id == client.client_id:
|
||||
# Check if expired
|
||||
if datetime.utcnow().timestamp() > code.expires_at:
|
||||
del self.authorization_codes[authorization_code]
|
||||
return None
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: BasicMemoryAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange an authorization code for tokens."""
|
||||
# Generate tokens
|
||||
access_token = self._generate_access_token(client.client_id, authorization_code.scopes)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store tokens
|
||||
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
|
||||
|
||||
self.access_tokens[access_token] = BasicMemoryAccessToken(
|
||||
token=access_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
expires_at=int(expires_at),
|
||||
)
|
||||
|
||||
self.refresh_tokens[refresh_token] = BasicMemoryRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
)
|
||||
|
||||
# Remove used authorization code
|
||||
del self.authorization_codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=3600, # 1 hour
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[BasicMemoryRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token = self.refresh_tokens.get(refresh_token)
|
||||
|
||||
if token and token.client_id == client.client_id:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: BasicMemoryRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange a refresh token for new tokens."""
|
||||
# Use requested scopes or original scopes
|
||||
token_scopes = scopes if scopes else refresh_token.scopes
|
||||
|
||||
# Generate new tokens
|
||||
new_access_token = self._generate_access_token(client.client_id, token_scopes)
|
||||
new_refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store new tokens
|
||||
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
|
||||
|
||||
self.access_tokens[new_access_token] = BasicMemoryAccessToken(
|
||||
token=new_access_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_scopes,
|
||||
expires_at=int(expires_at),
|
||||
)
|
||||
|
||||
self.refresh_tokens[new_refresh_token] = BasicMemoryRefreshToken(
|
||||
token=new_refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_scopes,
|
||||
)
|
||||
|
||||
# Remove old tokens
|
||||
del self.refresh_tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=3600, # 1 hour
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(token_scopes) if token_scopes else None,
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[BasicMemoryAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
logger.debug("Loading access token, checking in-memory store first")
|
||||
access_token = self.access_tokens.get(token)
|
||||
|
||||
if access_token:
|
||||
# Check if expired
|
||||
if access_token.expires_at and datetime.utcnow().timestamp() > access_token.expires_at:
|
||||
logger.debug("Token found in memory but expired, removing")
|
||||
del self.access_tokens[token]
|
||||
return None
|
||||
logger.debug("Token found in memory and valid")
|
||||
return access_token
|
||||
|
||||
# Try to decode as JWT
|
||||
logger.debug("Token not in memory, attempting JWT decode with secret key")
|
||||
try:
|
||||
# Decode with audience verification - PyJWT expects the audience to match
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.secret_key,
|
||||
algorithms=["HS256"],
|
||||
audience="basic-memory", # Expecting this audience
|
||||
issuer=self.issuer_url, # And this issuer
|
||||
)
|
||||
logger.debug(f"JWT decoded successfully: {payload}")
|
||||
return BasicMemoryAccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("sub", ""),
|
||||
scopes=payload.get("scopes", []),
|
||||
expires_at=payload.get("exp"),
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.error(f"JWT decode failed: {e}")
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: BasicMemoryAccessToken | BasicMemoryRefreshToken) -> None:
|
||||
"""Revoke an access or refresh token."""
|
||||
if isinstance(token, BasicMemoryAccessToken):
|
||||
self.access_tokens.pop(token.token, None)
|
||||
else:
|
||||
self.refresh_tokens.pop(token.token, None)
|
||||
|
||||
def _generate_access_token(self, client_id: str, scopes: list[str]) -> str:
|
||||
"""Generate a JWT access token."""
|
||||
payload = {
|
||||
"iss": self.issuer_url,
|
||||
"sub": client_id,
|
||||
"aud": "basic-memory",
|
||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||
"iat": datetime.utcnow(),
|
||||
"scopes": scopes,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, self.secret_key, algorithm="HS256")
|
||||
@@ -1,321 +0,0 @@
|
||||
"""External OAuth provider integration for Basic Memory MCP server."""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalAuthorizationCode(AuthorizationCode):
|
||||
"""Authorization code with external provider metadata."""
|
||||
|
||||
external_code: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalRefreshToken(RefreshToken):
|
||||
"""Refresh token with external provider metadata."""
|
||||
|
||||
external_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalAccessToken(AccessToken):
|
||||
"""Access token with external provider metadata."""
|
||||
|
||||
external_token: Optional[str] = None
|
||||
|
||||
|
||||
class ExternalOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
ExternalAuthorizationCode, ExternalRefreshToken, ExternalAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider that delegates to external OAuth providers.
|
||||
|
||||
This provider can integrate with services like:
|
||||
- GitHub OAuth
|
||||
- Google OAuth
|
||||
- Auth0
|
||||
- Okta
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
issuer_url: str,
|
||||
external_provider: str,
|
||||
external_client_id: str,
|
||||
external_client_secret: str,
|
||||
external_authorize_url: str,
|
||||
external_token_url: str,
|
||||
external_userinfo_url: Optional[str] = None,
|
||||
):
|
||||
self.issuer_url = issuer_url
|
||||
self.external_provider = external_provider
|
||||
self.external_client_id = external_client_id
|
||||
self.external_client_secret = external_client_secret
|
||||
self.external_authorize_url = external_authorize_url
|
||||
self.external_token_url = external_token_url
|
||||
self.external_userinfo_url = external_userinfo_url
|
||||
|
||||
# In-memory storage - in production, use a database
|
||||
self.clients: Dict[str, OAuthClientInformationFull] = {}
|
||||
self.codes: Dict[str, ExternalAuthorizationCode] = {}
|
||||
self.tokens: Dict[str, Any] = {}
|
||||
|
||||
self.http_client = httpx.AsyncClient()
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client by ID."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client."""
|
||||
self.clients[client_info.client_id] = client_info
|
||||
logger.info(f"Registered external OAuth client: {client_info.client_id}")
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create authorization URL redirecting to external provider."""
|
||||
# Store authorization request
|
||||
import secrets
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
self.codes[state] = ExternalAuthorizationCode(
|
||||
code=state,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=0, # Will be set by external provider
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
state=params.state,
|
||||
)
|
||||
|
||||
# Build external provider URL
|
||||
external_params = {
|
||||
"client_id": self.external_client_id,
|
||||
"redirect_uri": f"{self.issuer_url}/callback",
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"scope": " ".join(params.scopes or []),
|
||||
}
|
||||
|
||||
return construct_redirect_uri(self.external_authorize_url, **external_params)
|
||||
|
||||
async def handle_callback(self, code: str, state: str) -> str:
|
||||
"""Handle callback from external provider."""
|
||||
# Get original authorization request
|
||||
auth_code = self.codes.get(state)
|
||||
if not auth_code:
|
||||
raise ValueError("Invalid state parameter")
|
||||
|
||||
# Exchange code with external provider
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": f"{self.issuer_url}/callback",
|
||||
"client_id": self.external_client_id,
|
||||
"client_secret": self.external_client_secret,
|
||||
}
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.external_token_url,
|
||||
data=token_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
external_tokens = response.json()
|
||||
|
||||
# Store external tokens
|
||||
import secrets
|
||||
|
||||
internal_code = secrets.token_urlsafe(32)
|
||||
|
||||
self.codes[internal_code] = ExternalAuthorizationCode(
|
||||
code=internal_code,
|
||||
scopes=auth_code.scopes,
|
||||
expires_at=0,
|
||||
client_id=auth_code.client_id,
|
||||
code_challenge=auth_code.code_challenge,
|
||||
redirect_uri=auth_code.redirect_uri,
|
||||
redirect_uri_provided_explicitly=auth_code.redirect_uri_provided_explicitly,
|
||||
external_code=code,
|
||||
state=auth_code.state,
|
||||
)
|
||||
|
||||
self.tokens[internal_code] = external_tokens
|
||||
|
||||
# Redirect to original client
|
||||
return construct_redirect_uri(
|
||||
str(auth_code.redirect_uri),
|
||||
code=internal_code,
|
||||
state=auth_code.state,
|
||||
)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[ExternalAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.codes.get(authorization_code)
|
||||
if code and code.client_id == client.client_id:
|
||||
return code
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: ExternalAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
# Get stored external tokens
|
||||
external_tokens = self.tokens.get(authorization_code.code)
|
||||
if not external_tokens:
|
||||
raise ValueError("No tokens found for authorization code")
|
||||
|
||||
# Map external tokens to MCP tokens
|
||||
access_token = external_tokens.get("access_token")
|
||||
refresh_token = external_tokens.get("refresh_token")
|
||||
expires_in = external_tokens.get("expires_in", 3600)
|
||||
|
||||
# Store the mapping
|
||||
self.tokens[access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": access_token,
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
if refresh_token:
|
||||
self.tokens[refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": refresh_token,
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Clean up authorization code
|
||||
del self.codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[ExternalRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token_info = self.tokens.get(refresh_token)
|
||||
if token_info and token_info["client_id"] == client.client_id:
|
||||
return ExternalRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_info["scopes"],
|
||||
external_token=token_info.get("external_token"),
|
||||
)
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: ExternalRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token for new tokens."""
|
||||
# Exchange with external provider
|
||||
token_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token.external_token or refresh_token.token,
|
||||
"client_id": self.external_client_id,
|
||||
"client_secret": self.external_client_secret,
|
||||
}
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.external_token_url,
|
||||
data=token_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
external_tokens = response.json()
|
||||
|
||||
# Update stored tokens
|
||||
new_access_token = external_tokens.get("access_token")
|
||||
new_refresh_token = external_tokens.get("refresh_token", refresh_token.token)
|
||||
expires_in = external_tokens.get("expires_in", 3600)
|
||||
|
||||
self.tokens[new_access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": new_access_token,
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
if new_refresh_token != refresh_token.token:
|
||||
self.tokens[new_refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": new_refresh_token,
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
del self.tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(scopes or refresh_token.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[ExternalAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
token_info = self.tokens.get(token)
|
||||
if token_info:
|
||||
return ExternalAccessToken(
|
||||
token=token,
|
||||
client_id=token_info["client_id"],
|
||||
scopes=token_info["scopes"],
|
||||
external_token=token_info.get("external_token"),
|
||||
)
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: ExternalAccessToken | ExternalRefreshToken) -> None:
|
||||
"""Revoke a token."""
|
||||
self.tokens.pop(token.token, None)
|
||||
|
||||
|
||||
def create_github_provider() -> ExternalOAuthProvider:
|
||||
"""Create an OAuth provider for GitHub integration."""
|
||||
return ExternalOAuthProvider(
|
||||
issuer_url=os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000"),
|
||||
external_provider="github",
|
||||
external_client_id=os.getenv("GITHUB_CLIENT_ID", ""),
|
||||
external_client_secret=os.getenv("GITHUB_CLIENT_SECRET", ""),
|
||||
external_authorize_url="https://github.com/login/oauth/authorize",
|
||||
external_token_url="https://github.com/login/oauth/access_token",
|
||||
external_userinfo_url="https://api.github.com/user",
|
||||
)
|
||||
|
||||
|
||||
def create_google_provider() -> ExternalOAuthProvider:
|
||||
"""Create an OAuth provider for Google integration."""
|
||||
return ExternalOAuthProvider(
|
||||
issuer_url=os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000"),
|
||||
external_provider="google",
|
||||
external_client_id=os.getenv("GOOGLE_CLIENT_ID", ""),
|
||||
external_client_secret=os.getenv("GOOGLE_CLIENT_SECRET", ""),
|
||||
external_authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
external_token_url="https://oauth2.googleapis.com/token",
|
||||
external_userinfo_url="https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import ProjectConfig, get_project_config, config_manager
|
||||
from basic_memory.config import ProjectConfig, get_project_config, ConfigManager
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -23,7 +23,7 @@ class ProjectSession:
|
||||
current_project: Optional[str] = None
|
||||
default_project: Optional[str] = None
|
||||
|
||||
def initialize(self, default_project: str) -> None:
|
||||
def initialize(self, default_project: str) -> "ProjectSession":
|
||||
"""Set the default project from config on startup.
|
||||
|
||||
Args:
|
||||
@@ -32,6 +32,7 @@ class ProjectSession:
|
||||
self.default_project = default_project
|
||||
self.current_project = default_project
|
||||
logger.info(f"Initialized project session with default project: {default_project}")
|
||||
return self
|
||||
|
||||
def get_current_project(self) -> str:
|
||||
"""Get the currently active project name.
|
||||
@@ -72,7 +73,7 @@ class ProjectSession:
|
||||
via CLI or API to ensure MCP session stays in sync.
|
||||
"""
|
||||
# Reload config to get latest default project
|
||||
current_config = config_manager.load_config()
|
||||
current_config = ConfigManager().config
|
||||
new_default = current_config.default_project
|
||||
|
||||
# Reinitialize with new default
|
||||
@@ -102,7 +103,8 @@ def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
|
||||
return project
|
||||
|
||||
current_project = session.get_current_project()
|
||||
return get_project_config(current_project)
|
||||
active_project = get_project_config(current_project)
|
||||
return active_project
|
||||
|
||||
|
||||
def add_project_metadata(result: str, project_name: str) -> str:
|
||||
|
||||
@@ -10,12 +10,10 @@ from basic_memory.mcp.prompts import continue_conversation
|
||||
from basic_memory.mcp.prompts import recent_activity
|
||||
from basic_memory.mcp.prompts import search
|
||||
from basic_memory.mcp.prompts import ai_assistant_guide
|
||||
from basic_memory.mcp.prompts import sync_status
|
||||
|
||||
__all__ = [
|
||||
"ai_assistant_guide",
|
||||
"continue_conversation",
|
||||
"recent_activity",
|
||||
"search",
|
||||
"sync_status",
|
||||
]
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Sync status prompt for Basic Memory MCP server."""
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
description="""Get sync status with recommendations for AI assistants.
|
||||
|
||||
This prompt provides both current sync status and guidance on how
|
||||
AI assistants should respond when sync operations are in progress or completed.
|
||||
""",
|
||||
)
|
||||
async def sync_status_prompt() -> str:
|
||||
"""Get sync status with AI assistant guidance.
|
||||
Returns:
|
||||
Formatted sync status with AI assistant guidance
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
state = migration_manager.state
|
||||
|
||||
# Build status report
|
||||
lines = [
|
||||
"# Basic Memory Sync Status",
|
||||
"",
|
||||
f"**Current Status**: {state.status.value.replace('_', ' ').title()}",
|
||||
f"**System Ready**: {'Yes' if migration_manager.is_ready else 'No'}",
|
||||
"",
|
||||
]
|
||||
|
||||
if migration_manager.is_ready:
|
||||
lines.extend(
|
||||
[
|
||||
"✅ **All sync operations completed** - System is fully operational",
|
||||
"",
|
||||
"All Basic Memory tools are available and functioning normally.",
|
||||
"File indexing is complete and knowledge graphs are up to date.",
|
||||
"You can proceed with any knowledge management tasks.",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.append(f"**Status Message**: {state.message}")
|
||||
|
||||
if state.status.value == "in_progress":
|
||||
if state.projects_total > 0:
|
||||
progress = f" ({state.projects_migrated}/{state.projects_total})"
|
||||
lines.append(f"**Progress**: {progress}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"🔄 **File synchronization in progress** - Processing files and building knowledge graphs",
|
||||
"",
|
||||
"**Impact**: Some tools may show status messages instead of normal responses",
|
||||
"until sync completes (usually 1-3 minutes).",
|
||||
]
|
||||
)
|
||||
|
||||
elif state.status.value == "failed":
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"❌ **Sync failed**: {state.error or 'Unknown error'}",
|
||||
"",
|
||||
"**Impact**: System may have limited functionality until issue is resolved.",
|
||||
]
|
||||
)
|
||||
|
||||
# Add AI assistant recommendations
|
||||
if not migration_manager.is_ready:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## AI Assistant Recommendations",
|
||||
"",
|
||||
"**When sync is in progress:**",
|
||||
"- Inform the user about the background file processing",
|
||||
"- Suggest using `sync_status()` tool to check progress",
|
||||
"- Explain that tools will work normally once sync completes",
|
||||
"- Avoid creating complex workflows until sync is done",
|
||||
"",
|
||||
"**What to tell users:**",
|
||||
"- 'Basic Memory is processing your files and building knowledge graphs'",
|
||||
"- 'This usually takes 1-3 minutes depending on your content size'",
|
||||
"- 'You can check progress anytime with the sync_status tool'",
|
||||
"- 'Full functionality will be available once processing completes'",
|
||||
"",
|
||||
"**User-friendly language:**",
|
||||
"- Say 'processing files' instead of 'migration' or 'sync'",
|
||||
"- Say 'building knowledge graphs' instead of 'indexing'",
|
||||
"- Say 'setting up your knowledge base' instead of 'running migrations'",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
return f"""# Sync Status - Error
|
||||
|
||||
❌ **Unable to check sync status**: {str(e)}
|
||||
|
||||
## AI Assistant Recommendations
|
||||
|
||||
**When status is unavailable:**
|
||||
- Assume the system is likely working normally
|
||||
- Try proceeding with normal operations
|
||||
- If users report issues, suggest checking logs or restarting
|
||||
- Use user-friendly language about 'setting up the knowledge base'
|
||||
"""
|
||||
@@ -7,25 +7,10 @@ from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional, Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.logging import configure_logging as mcp_configure_logging
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
||||
from basic_memory.mcp.project_session import session
|
||||
from basic_memory.mcp.external_auth_provider import (
|
||||
create_github_provider,
|
||||
create_google_provider,
|
||||
)
|
||||
from basic_memory.mcp.supabase_auth_provider import SupabaseOAuthProvider
|
||||
|
||||
# mcp console logging
|
||||
mcp_configure_logging(level="ERROR")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -36,7 +21,11 @@ class AppContext:
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
""" """
|
||||
# defer import so tests can monkeypatch
|
||||
from basic_memory.mcp.project_session import session
|
||||
|
||||
app_config = ConfigManager().config
|
||||
# Initialize on startup (now returns migration_manager)
|
||||
migration_manager = await initialize_app(app_config)
|
||||
|
||||
@@ -50,60 +39,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
|
||||
pass
|
||||
|
||||
|
||||
# OAuth configuration function
|
||||
def create_auth_config() -> tuple[AuthSettings | None, Any | None]:
|
||||
"""Create OAuth configuration if enabled."""
|
||||
# Check if OAuth is enabled via environment variable
|
||||
import os
|
||||
|
||||
if os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true":
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
# Configure OAuth settings
|
||||
issuer_url = os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000")
|
||||
required_scopes = os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES", "read,write")
|
||||
docs_url = os.getenv("FASTMCP_AUTH_DOCS_URL") or "http://localhost:8000/docs/oauth"
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(issuer_url),
|
||||
service_documentation_url=AnyHttpUrl(docs_url),
|
||||
required_scopes=required_scopes.split(",") if required_scopes else ["read", "write"],
|
||||
)
|
||||
|
||||
# Create OAuth provider based on type
|
||||
provider_type = os.getenv("FASTMCP_AUTH_PROVIDER", "basic").lower()
|
||||
|
||||
if provider_type == "github":
|
||||
auth_provider = create_github_provider()
|
||||
elif provider_type == "google":
|
||||
auth_provider = create_google_provider()
|
||||
elif provider_type == "supabase":
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
supabase_anon_key = os.getenv("SUPABASE_ANON_KEY")
|
||||
supabase_service_key = os.getenv("SUPABASE_SERVICE_KEY")
|
||||
|
||||
if not supabase_url or not supabase_anon_key:
|
||||
raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for Supabase auth")
|
||||
|
||||
auth_provider = SupabaseOAuthProvider(
|
||||
supabase_url=supabase_url,
|
||||
supabase_anon_key=supabase_anon_key,
|
||||
supabase_service_key=supabase_service_key,
|
||||
issuer_url=issuer_url,
|
||||
)
|
||||
else: # default to "basic"
|
||||
auth_provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
return auth_settings, auth_provider
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# Create auth configuration
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
# Create the shared server instance
|
||||
# Create the shared server instance with custom Stytch auth
|
||||
mcp = FastMCP(
|
||||
name="Basic Memory",
|
||||
auth=auth_provider,
|
||||
lifespan=app_lifespan,
|
||||
)
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
"""Supabase OAuth provider for Basic Memory MCP server."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from loguru import logger
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
TokenError,
|
||||
AuthorizeError,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseAuthorizationCode(AuthorizationCode):
|
||||
"""Authorization code with Supabase metadata."""
|
||||
|
||||
user_id: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseRefreshToken(RefreshToken):
|
||||
"""Refresh token with Supabase metadata."""
|
||||
|
||||
supabase_refresh_token: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseAccessToken(AccessToken):
|
||||
"""Access token with Supabase metadata."""
|
||||
|
||||
supabase_access_token: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class SupabaseOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
SupabaseAuthorizationCode, SupabaseRefreshToken, SupabaseAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider that integrates with Supabase Auth.
|
||||
|
||||
This provider uses Supabase as the authentication backend while
|
||||
maintaining compatibility with MCP's OAuth requirements.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supabase_url: str,
|
||||
supabase_anon_key: str,
|
||||
supabase_service_key: Optional[str] = None,
|
||||
issuer_url: str = "http://localhost:8000",
|
||||
):
|
||||
self.supabase_url = supabase_url.rstrip("/")
|
||||
self.supabase_anon_key = supabase_anon_key
|
||||
self.supabase_service_key = supabase_service_key or supabase_anon_key
|
||||
self.issuer_url = issuer_url
|
||||
|
||||
# HTTP client for Supabase API calls
|
||||
self.http_client = httpx.AsyncClient()
|
||||
|
||||
# Temporary storage for auth flows (in production, use Supabase DB)
|
||||
self.pending_auth_codes: Dict[str, SupabaseAuthorizationCode] = {}
|
||||
self.mcp_to_supabase_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client from Supabase.
|
||||
|
||||
In production, this would query a clients table in Supabase.
|
||||
"""
|
||||
# For now, we'll validate against a configured list of allowed clients
|
||||
# In production, query Supabase DB for client info
|
||||
allowed_clients = os.getenv("SUPABASE_ALLOWED_CLIENTS", "").split(",")
|
||||
|
||||
if client_id in allowed_clients:
|
||||
return OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_secret="", # Supabase handles secrets
|
||||
redirect_uris=[], # Supabase handles redirect URIs
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client in Supabase.
|
||||
|
||||
In production, this would insert into a clients table.
|
||||
"""
|
||||
# For development, we just log the registration
|
||||
logger.info(f"Would register client {client_info.client_id} in Supabase")
|
||||
|
||||
# In production:
|
||||
# await self.supabase.table('oauth_clients').insert({
|
||||
# 'client_id': client_info.client_id,
|
||||
# 'client_secret': client_info.client_secret,
|
||||
# 'metadata': client_info.client_metadata,
|
||||
# }).execute()
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create authorization URL redirecting to Supabase Auth.
|
||||
|
||||
This initiates the OAuth flow with Supabase as the identity provider.
|
||||
"""
|
||||
# Generate state for this auth request
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the authorization request
|
||||
self.pending_auth_codes[state] = SupabaseAuthorizationCode(
|
||||
code=state,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
)
|
||||
|
||||
# Build Supabase auth URL
|
||||
auth_params = {
|
||||
"redirect_to": f"{self.issuer_url}/auth/callback",
|
||||
"scopes": " ".join(params.scopes or ["openid", "email"]),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
# Use Supabase's OAuth endpoint
|
||||
auth_url = f"{self.supabase_url}/auth/v1/authorize"
|
||||
query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
|
||||
|
||||
return f"{auth_url}?{query_string}"
|
||||
|
||||
async def handle_supabase_callback(self, code: str, state: str) -> str:
|
||||
"""Handle callback from Supabase after user authentication."""
|
||||
# Get the original auth request
|
||||
auth_request = self.pending_auth_codes.get(state)
|
||||
if not auth_request:
|
||||
raise AuthorizeError(
|
||||
error="invalid_request",
|
||||
error_description="Invalid state parameter",
|
||||
)
|
||||
|
||||
# Exchange code with Supabase for tokens
|
||||
token_response = await self.http_client.post(
|
||||
f"{self.supabase_url}/auth/v1/token",
|
||||
json={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": f"{self.issuer_url}/auth/callback",
|
||||
},
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {self.supabase_anon_key}",
|
||||
},
|
||||
)
|
||||
|
||||
if not token_response.is_success:
|
||||
raise AuthorizeError(
|
||||
error="server_error",
|
||||
error_description="Failed to exchange code with Supabase",
|
||||
)
|
||||
|
||||
supabase_tokens = token_response.json()
|
||||
|
||||
# Get user info from Supabase
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
||||
},
|
||||
)
|
||||
|
||||
user_data = user_response.json() if user_response.is_success else {}
|
||||
|
||||
# Generate MCP authorization code
|
||||
mcp_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Update auth request with user info
|
||||
auth_request.code = mcp_code
|
||||
auth_request.user_id = user_data.get("id")
|
||||
auth_request.email = user_data.get("email")
|
||||
|
||||
# Store mapping
|
||||
self.pending_auth_codes[mcp_code] = auth_request
|
||||
self.mcp_to_supabase_tokens[mcp_code] = {
|
||||
"supabase_tokens": supabase_tokens,
|
||||
"user": user_data,
|
||||
}
|
||||
|
||||
# Clean up old state
|
||||
del self.pending_auth_codes[state]
|
||||
|
||||
# Redirect back to client
|
||||
redirect_uri = str(auth_request.redirect_uri)
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
|
||||
return f"{redirect_uri}{separator}code={mcp_code}&state={state}"
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[SupabaseAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.pending_auth_codes.get(authorization_code)
|
||||
|
||||
if code and code.client_id == client.client_id:
|
||||
# Check expiration
|
||||
if datetime.utcnow().timestamp() > code.expires_at:
|
||||
del self.pending_auth_codes[authorization_code]
|
||||
return None
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: SupabaseAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
# Get stored Supabase tokens
|
||||
token_data = self.mcp_to_supabase_tokens.get(authorization_code.code)
|
||||
if not token_data:
|
||||
raise TokenError(error="invalid_grant", error_description="Invalid authorization code")
|
||||
|
||||
supabase_tokens = token_data["supabase_tokens"]
|
||||
user = token_data["user"]
|
||||
|
||||
# Generate MCP tokens that wrap Supabase tokens
|
||||
access_token = self._generate_mcp_token(
|
||||
client_id=client.client_id,
|
||||
user_id=user.get("id", ""),
|
||||
email=user.get("email", ""),
|
||||
scopes=authorization_code.scopes,
|
||||
supabase_access_token=supabase_tokens["access_token"],
|
||||
)
|
||||
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the token mapping
|
||||
self.mcp_to_supabase_tokens[access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user.get("id"),
|
||||
"email": user.get("email"),
|
||||
"supabase_access_token": supabase_tokens["access_token"],
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Store refresh token mapping
|
||||
self.mcp_to_supabase_tokens[refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user.get("id"),
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Clean up authorization code
|
||||
del self.pending_auth_codes[authorization_code.code]
|
||||
del self.mcp_to_supabase_tokens[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=supabase_tokens.get("expires_in", 3600),
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[SupabaseRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token_data = self.mcp_to_supabase_tokens.get(refresh_token)
|
||||
|
||||
if token_data and token_data["client_id"] == client.client_id:
|
||||
return SupabaseRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_data["scopes"],
|
||||
supabase_refresh_token=token_data["supabase_refresh_token"],
|
||||
user_id=token_data.get("user_id"),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: SupabaseRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token for new tokens using Supabase."""
|
||||
# Refresh with Supabase
|
||||
token_response = await self.http_client.post(
|
||||
f"{self.supabase_url}/auth/v1/token",
|
||||
json={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token.supabase_refresh_token,
|
||||
},
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {self.supabase_anon_key}",
|
||||
},
|
||||
)
|
||||
|
||||
if not token_response.is_success:
|
||||
raise TokenError(
|
||||
error="invalid_grant",
|
||||
error_description="Failed to refresh with Supabase",
|
||||
)
|
||||
|
||||
supabase_tokens = token_response.json()
|
||||
|
||||
# Get updated user info
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
||||
},
|
||||
)
|
||||
|
||||
user_data = user_response.json() if user_response.is_success else {}
|
||||
|
||||
# Generate new MCP tokens
|
||||
new_access_token = self._generate_mcp_token(
|
||||
client_id=client.client_id,
|
||||
user_id=user_data.get("id", ""),
|
||||
email=user_data.get("email", ""),
|
||||
scopes=scopes or refresh_token.scopes,
|
||||
supabase_access_token=supabase_tokens["access_token"],
|
||||
)
|
||||
|
||||
new_refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Update token mappings
|
||||
self.mcp_to_supabase_tokens[new_access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user_data.get("id"),
|
||||
"email": user_data.get("email"),
|
||||
"supabase_access_token": supabase_tokens["access_token"],
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
self.mcp_to_supabase_tokens[new_refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user_data.get("id"),
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
# Clean up old tokens
|
||||
del self.mcp_to_supabase_tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=supabase_tokens.get("expires_in", 3600),
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(scopes or refresh_token.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[SupabaseAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
# First check our mapping
|
||||
token_data = self.mcp_to_supabase_tokens.get(token)
|
||||
if token_data:
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id=token_data["client_id"],
|
||||
scopes=token_data["scopes"],
|
||||
supabase_access_token=token_data.get("supabase_access_token"),
|
||||
user_id=token_data.get("user_id"),
|
||||
email=token_data.get("email"),
|
||||
)
|
||||
|
||||
# Try to decode as JWT
|
||||
try:
|
||||
# Verify with Supabase's JWT secret
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
os.getenv("SUPABASE_JWT_SECRET", ""),
|
||||
algorithms=["HS256"],
|
||||
audience="authenticated",
|
||||
)
|
||||
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=payload.get("scopes", []),
|
||||
user_id=payload.get("sub"),
|
||||
email=payload.get("email"),
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
pass
|
||||
|
||||
# Validate with Supabase
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
|
||||
if user_response.is_success:
|
||||
user_data = user_response.json()
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id="", # Unknown client for direct Supabase tokens
|
||||
scopes=[],
|
||||
supabase_access_token=token,
|
||||
user_id=user_data.get("id"),
|
||||
email=user_data.get("email"),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: SupabaseAccessToken | SupabaseRefreshToken) -> None:
|
||||
"""Revoke a token."""
|
||||
# Remove from our mapping
|
||||
self.mcp_to_supabase_tokens.pop(token.token, None)
|
||||
|
||||
# In production, also revoke in Supabase:
|
||||
# await self.supabase.auth.admin.sign_out(token.user_id)
|
||||
|
||||
def _generate_mcp_token(
|
||||
self,
|
||||
client_id: str,
|
||||
user_id: str,
|
||||
email: str,
|
||||
scopes: list[str],
|
||||
supabase_access_token: str,
|
||||
) -> str:
|
||||
"""Generate an MCP token that wraps Supabase authentication."""
|
||||
payload = {
|
||||
"iss": self.issuer_url,
|
||||
"sub": user_id,
|
||||
"client_id": client_id,
|
||||
"email": email,
|
||||
"scopes": scopes,
|
||||
"supabase_token": supabase_access_token[:10] + "...", # Reference only
|
||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
# Use Supabase JWT secret if available
|
||||
secret = os.getenv("SUPABASE_JWT_SECRET", secrets.token_urlsafe(32))
|
||||
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
@@ -20,24 +20,24 @@ from basic_memory.mcp.tools.edit_note import edit_note
|
||||
from basic_memory.mcp.tools.move_note import move_note
|
||||
from basic_memory.mcp.tools.sync_status import sync_status
|
||||
from basic_memory.mcp.tools.project_management import (
|
||||
list_projects,
|
||||
list_memory_projects,
|
||||
switch_project,
|
||||
get_current_project,
|
||||
set_default_project,
|
||||
create_project,
|
||||
create_memory_project,
|
||||
delete_project,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
"canvas",
|
||||
"create_project",
|
||||
"create_memory_project",
|
||||
"delete_note",
|
||||
"delete_project",
|
||||
"edit_note",
|
||||
"get_current_project",
|
||||
"list_directory",
|
||||
"list_projects",
|
||||
"list_memory_projects",
|
||||
"move_note",
|
||||
"read_content",
|
||||
"read_note",
|
||||
|
||||
@@ -82,10 +82,15 @@ async def build_context(
|
||||
logger.info(f"Building context from {url}")
|
||||
# URL is already validated and normalized by MemoryUrl type annotation
|
||||
|
||||
# Get the active project first to check project-specific sync status
|
||||
active_project = get_active_project(project)
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
migration_status = await wait_for_migration_or_return_status(
|
||||
timeout=5.0, project_name=active_project.name
|
||||
)
|
||||
if migration_status: # pragma: no cover
|
||||
# Return a proper GraphContext with status message
|
||||
from basic_memory.schemas.memory import MemoryMetadata
|
||||
@@ -102,8 +107,6 @@ async def build_context(
|
||||
uri=migration_status, # Include status in metadata
|
||||
),
|
||||
)
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_get(
|
||||
|
||||
@@ -7,9 +7,145 @@ from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
async def _detect_cross_project_move_attempt(
|
||||
identifier: str, destination_path: str, current_project: str
|
||||
) -> Optional[str]:
|
||||
"""Detect potential cross-project move attempts and return guidance.
|
||||
|
||||
Args:
|
||||
identifier: The note identifier being moved
|
||||
destination_path: The destination path
|
||||
current_project: The current active project
|
||||
|
||||
Returns:
|
||||
Error message with guidance if cross-project move is detected, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Get list of all available projects to check against
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
project_names = [p.name.lower() for p in project_list.projects]
|
||||
|
||||
# Check if destination path contains any project names
|
||||
dest_lower = destination_path.lower()
|
||||
path_parts = dest_lower.split("/")
|
||||
|
||||
# Look for project names in the destination path
|
||||
for part in path_parts:
|
||||
if part in project_names and part != current_project.lower():
|
||||
# Found a different project name in the path
|
||||
matching_project = next(
|
||||
p.name for p in project_list.projects if p.name.lower() == part
|
||||
)
|
||||
return _format_cross_project_error_response(
|
||||
identifier, destination_path, current_project, matching_project
|
||||
)
|
||||
|
||||
# No other cross-project patterns detected
|
||||
|
||||
except Exception as e:
|
||||
# If we can't detect, don't interfere with normal error handling
|
||||
logger.debug(f"Could not check for cross-project move: {e}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_cross_project_error_response(
|
||||
identifier: str, destination_path: str, current_project: str, target_project: str
|
||||
) -> str:
|
||||
"""Format error response for detected cross-project move attempts."""
|
||||
return dedent(f"""
|
||||
# Move Failed - Cross-Project Move Not Supported
|
||||
|
||||
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
|
||||
|
||||
**Current project:** {current_project}
|
||||
**Target project:** {target_project}
|
||||
|
||||
## Cross-project moves are not supported directly
|
||||
|
||||
Notes can only be moved within the same project. To move content between projects, use this workflow:
|
||||
|
||||
### Recommended approach:
|
||||
```
|
||||
# 1. Read the note content from current project
|
||||
read_note("{identifier}")
|
||||
|
||||
# 2. Switch to the target project
|
||||
switch_project("{target_project}")
|
||||
|
||||
# 3. Create the note in the target project
|
||||
write_note("Note Title", "content from step 1", "target-folder")
|
||||
|
||||
# 4. Switch back to original project (optional)
|
||||
switch_project("{current_project}")
|
||||
|
||||
# 5. Delete the original note if desired
|
||||
delete_note("{identifier}")
|
||||
```
|
||||
|
||||
### Alternative: Stay in current project
|
||||
If you want to move the note within the **{current_project}** project only:
|
||||
```
|
||||
move_note("{identifier}", "new-folder/new-name.md")
|
||||
```
|
||||
|
||||
## Available projects:
|
||||
Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
|
||||
""").strip()
|
||||
|
||||
|
||||
def _format_potential_cross_project_guidance(
|
||||
identifier: str, destination_path: str, current_project: str, available_projects: list[str]
|
||||
) -> str:
|
||||
"""Format guidance for potentially cross-project moves."""
|
||||
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
|
||||
if len(available_projects) > 3:
|
||||
other_projects += f" (and {len(available_projects) - 3} others)"
|
||||
|
||||
return dedent(f"""
|
||||
# Move Failed - Check Project Context
|
||||
|
||||
Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
|
||||
|
||||
## If you intended to move within the current project:
|
||||
The destination path should be relative to the project root:
|
||||
```
|
||||
move_note("{identifier}", "folder/filename.md")
|
||||
```
|
||||
|
||||
## If you intended to move to a different project:
|
||||
Cross-project moves require switching projects first. Available projects: {other_projects}
|
||||
|
||||
### To move to another project:
|
||||
```
|
||||
# 1. Read the content
|
||||
read_note("{identifier}")
|
||||
|
||||
# 2. Switch to target project
|
||||
switch_project("target-project-name")
|
||||
|
||||
# 3. Create note in target project
|
||||
write_note("Title", "content", "folder")
|
||||
|
||||
# 4. Switch back and delete original if desired
|
||||
switch_project("{current_project}")
|
||||
delete_note("{identifier}")
|
||||
```
|
||||
|
||||
### To see all projects:
|
||||
```
|
||||
list_projects()
|
||||
```
|
||||
""").strip()
|
||||
|
||||
|
||||
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
|
||||
@@ -258,6 +394,36 @@ async def move_note(
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Validate destination path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if not validate_project_path(destination_path, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
return f"""# Move Failed - Security Validation Error
|
||||
|
||||
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
||||
|
||||
## Valid path examples:
|
||||
- `notes/my-file.md`
|
||||
- `projects/2025/meeting-notes.md`
|
||||
- `archive/old-notes.md`
|
||||
|
||||
## Try again with a safe path:
|
||||
```
|
||||
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
||||
```"""
|
||||
|
||||
# Check for potential cross-project move attempts
|
||||
cross_project_error = await _detect_cross_project_move_attempt(
|
||||
identifier, destination_path, active_project.name
|
||||
)
|
||||
if cross_project_error:
|
||||
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
||||
return cross_project_error
|
||||
|
||||
try:
|
||||
# Prepare move request
|
||||
move_data = {
|
||||
|
||||
@@ -5,6 +5,7 @@ and manage project context during conversations.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
@@ -19,7 +20,9 @@ from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@mcp.tool("list_memory_projects")
|
||||
async def list_projects(ctx: Context | None = None) -> str:
|
||||
async def list_memory_projects(
|
||||
ctx: Context | None = None, _compatibility: Optional[str] = None
|
||||
) -> str:
|
||||
"""List all available projects with their status.
|
||||
|
||||
Shows all Basic Memory projects that are available, indicating which one
|
||||
@@ -29,7 +32,7 @@ async def list_projects(ctx: Context | None = None) -> str:
|
||||
Formatted list of projects with status indicators
|
||||
|
||||
Example:
|
||||
list_projects()
|
||||
list_memory_projects()
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info("Listing all available projects")
|
||||
@@ -144,13 +147,13 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
Your session remains on the previous project.
|
||||
|
||||
## Troubleshooting:
|
||||
1. **Check available projects**: Use `list_projects()` to see valid project names
|
||||
1. **Check available projects**: Use `list_memory_projects()` to see valid project names
|
||||
2. **Verify spelling**: Ensure the project name is spelled correctly
|
||||
3. **Check permissions**: Verify you have access to the requested project
|
||||
4. **Try again**: The error might be temporary
|
||||
|
||||
## Available options:
|
||||
- See all projects: `list_projects()`
|
||||
- See all projects: `list_memory_projects()`
|
||||
- Stay on current project: `get_current_project()`
|
||||
- Try different project: `switch_project("correct-project-name")`
|
||||
|
||||
@@ -159,7 +162,9 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_current_project(ctx: Context | None = None) -> str:
|
||||
async def get_current_project(
|
||||
ctx: Context | None = None, _compatibility: Optional[str] = None
|
||||
) -> str:
|
||||
"""Show the currently active project and basic stats.
|
||||
|
||||
Displays which project is currently active and provides basic information
|
||||
@@ -231,7 +236,7 @@ async def set_default_project(project_name: str, ctx: Context | None = None) ->
|
||||
|
||||
|
||||
@mcp.tool("create_memory_project")
|
||||
async def create_project(
|
||||
async def create_memory_project(
|
||||
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
|
||||
) -> str:
|
||||
"""Create a new Basic Memory project.
|
||||
@@ -248,8 +253,8 @@ async def create_project(
|
||||
Confirmation message with project details
|
||||
|
||||
Example:
|
||||
create_project("my-research", "~/Documents/research")
|
||||
create_project("work-notes", "/home/user/work", set_default=True)
|
||||
create_memory_project("my-research", "~/Documents/research")
|
||||
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info(f"Creating project: {project_name} at {project_path}")
|
||||
|
||||
@@ -17,6 +17,7 @@ from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
def calculate_target_params(content_length):
|
||||
@@ -188,6 +189,21 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
|
||||
project_url = active_project.project_url
|
||||
|
||||
url = memory_url_path(path)
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if not validate_project_path(url, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
path=path,
|
||||
url=url,
|
||||
project=active_project.name,
|
||||
)
|
||||
return {
|
||||
"type": "error",
|
||||
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
||||
}
|
||||
|
||||
response = await call_get(client, f"{project_url}/resource/{url}")
|
||||
content_type = response.headers.get("content-type", "application/octet-stream")
|
||||
content_length = int(response.headers.get("content-length", 0))
|
||||
|
||||
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -52,18 +53,32 @@ async def read_note(
|
||||
read_note("Meeting Notes", project="work-project")
|
||||
"""
|
||||
|
||||
# Get the active project first to check project-specific sync status
|
||||
active_project = get_active_project(project)
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
migration_status = await wait_for_migration_or_return_status(
|
||||
timeout=5.0, project_name=active_project.name
|
||||
)
|
||||
if migration_status: # pragma: no cover
|
||||
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Get the file via REST API - first try direct permalink lookup
|
||||
entity_path = memory_url_path(identifier)
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if not validate_project_path(entity_path, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
identifier=identifier,
|
||||
entity_path=entity_path,
|
||||
project=active_project.name,
|
||||
)
|
||||
return f"# Error\n\nPath '{identifier}' is not allowed - paths must stay within project boundaries"
|
||||
path = f"{project_url}/resource/{entity_path}"
|
||||
logger.info(f"Attempting to read note from URL: {path}")
|
||||
|
||||
|
||||
@@ -45,13 +45,18 @@ def _format_search_error_response(error_message: str, query: str, search_type: s
|
||||
- Boolean OR: `meeting OR discussion`
|
||||
- Boolean NOT: `project NOT archived`
|
||||
- Grouped: `(project OR planning) AND notes`
|
||||
- Exact phrases: `"weekly standup meeting"`
|
||||
- Content-specific: `tag:example` or `category:observation`
|
||||
|
||||
## Try again with:
|
||||
```
|
||||
search_notes("INSERT_CLEAN_QUERY_HERE")
|
||||
search_notes("{clean_query}")
|
||||
```
|
||||
|
||||
Replace INSERT_CLEAN_QUERY_HERE with your simplified search terms.
|
||||
## Alternative search strategies:
|
||||
- Break into simpler terms: `search_notes("{" ".join(clean_query.split()[:2])}")`
|
||||
- Try different search types: `search_notes("{clean_query}", search_type="title")`
|
||||
- Use filtering: `search_notes("{clean_query}", types=["entity"])`
|
||||
""").strip()
|
||||
|
||||
# Project not found errors (check before general "not found")
|
||||
@@ -85,24 +90,39 @@ def _format_search_error_response(error_message: str, query: str, search_type: s
|
||||
|
||||
No content found matching '{query}' in the current project.
|
||||
|
||||
## Suggestions to try:
|
||||
## Search strategy suggestions:
|
||||
1. **Broaden your search**: Try fewer or more general terms
|
||||
- Instead of: `{query}`
|
||||
- Try: `{simplified_query}`
|
||||
|
||||
2. **Check spelling**: Verify terms are spelled correctly
|
||||
3. **Try different search types**:
|
||||
- Text search: `search_notes("{query}", search_type="text")`
|
||||
- Title search: `search_notes("{query}", search_type="title")`
|
||||
- Permalink search: `search_notes("{query}", search_type="permalink")`
|
||||
2. **Check spelling and try variations**:
|
||||
- Verify terms are spelled correctly
|
||||
- Try synonyms or related terms
|
||||
|
||||
4. **Use boolean operators**:
|
||||
- Try OR search for broader results
|
||||
3. **Use different search approaches**:
|
||||
- **Text search**: `search_notes("{query}", search_type="text")` (searches full content)
|
||||
- **Title search**: `search_notes("{query}", search_type="title")` (searches only titles)
|
||||
- **Permalink search**: `search_notes("{query}", search_type="permalink")` (searches file paths)
|
||||
|
||||
## Check what content exists:
|
||||
- Recent activity: `recent_activity(timeframe="7d")`
|
||||
- List files: `list_directory("/")`
|
||||
- Browse by folder: `list_directory("/notes")` or `list_directory("/docs")`
|
||||
4. **Try boolean operators for broader results**:
|
||||
- OR search: `search_notes("{" OR ".join(query.split()[:3])}")`
|
||||
- Remove restrictive terms: Focus on the most important keywords
|
||||
|
||||
5. **Use filtering to narrow scope**:
|
||||
- By content type: `search_notes("{query}", types=["entity"])`
|
||||
- By recent content: `search_notes("{query}", after_date="1 week")`
|
||||
- By entity type: `search_notes("{query}", entity_types=["observation"])`
|
||||
|
||||
6. **Try advanced search patterns**:
|
||||
- Tag search: `search_notes("tag:your-tag")`
|
||||
- Category search: `search_notes("category:observation")`
|
||||
- Pattern matching: `search_notes("*{query}*", search_type="permalink")`
|
||||
|
||||
## Explore what content exists:
|
||||
- **Recent activity**: `recent_activity(timeframe="7d")` - See what's been updated recently
|
||||
- **List directories**: `list_directory("/")` - Browse all content
|
||||
- **Browse by folder**: `list_directory("/notes")` or `list_directory("/docs")`
|
||||
- **Check project**: `get_current_project()` - Verify you're in the right project
|
||||
""").strip()
|
||||
|
||||
# Server/API errors
|
||||
@@ -151,25 +171,36 @@ You don't have permission to search in the current project: {error_message}
|
||||
|
||||
Error searching for '{query}': {error_message}
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Check your query**: Ensure it uses valid search syntax
|
||||
2. **Try simpler terms**: Use basic words without special characters
|
||||
## Troubleshooting steps:
|
||||
1. **Simplify your query**: Try basic words without special characters
|
||||
2. **Check search syntax**: Ensure boolean operators are correctly formatted
|
||||
3. **Verify project access**: Make sure you can access the current project
|
||||
4. **Check recent activity**: `recent_activity(timeframe="7d")` to see if content exists
|
||||
4. **Test with simple search**: Try `search_notes("test")` to verify search is working
|
||||
|
||||
## Alternative approaches:
|
||||
- Browse files: `list_directory("/")`
|
||||
- Try different search type: `search_notes("{query}", search_type="title")`
|
||||
- Search with filters: `search_notes("{query}", types=["entity"])`
|
||||
## Alternative search approaches:
|
||||
- **Different search types**:
|
||||
- Title only: `search_notes("{query}", search_type="title")`
|
||||
- Permalink patterns: `search_notes("{query}*", search_type="permalink")`
|
||||
- **With filters**: `search_notes("{query}", types=["entity"])`
|
||||
- **Recent content**: `search_notes("{query}", after_date="1 week")`
|
||||
- **Boolean variations**: `search_notes("{" OR ".join(query.split()[:2])}")`
|
||||
|
||||
## Need help?
|
||||
- View recent changes: `recent_activity()`
|
||||
- List projects: `list_projects()`
|
||||
- Check current project: `get_current_project()`"""
|
||||
## Explore your content:
|
||||
- **Browse files**: `list_directory("/")` - See all available content
|
||||
- **Recent activity**: `recent_activity(timeframe="7d")` - Check what's been updated
|
||||
- **Project info**: `get_current_project()` - Verify current project
|
||||
- **All projects**: `list_projects()` - Switch to different project if needed
|
||||
|
||||
## Search syntax reference:
|
||||
- **Basic**: `keyword` or `multiple words`
|
||||
- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
|
||||
- **Phrases**: `"exact phrase"`
|
||||
- **Grouping**: `(term1 OR term2) AND term3`
|
||||
- **Patterns**: `tag:example`, `category:observation`"""
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base.",
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
)
|
||||
async def search_notes(
|
||||
query: str,
|
||||
@@ -181,24 +212,60 @@ async def search_notes(
|
||||
after_date: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> SearchResponse | str:
|
||||
"""Search across all content in the knowledge base.
|
||||
"""Search across all content in the knowledge base with comprehensive syntax support.
|
||||
|
||||
This tool searches the knowledge base using full-text search, pattern matching,
|
||||
or exact permalink lookup. It supports filtering by content type, entity type,
|
||||
and date.
|
||||
and date, with advanced boolean and phrase search capabilities.
|
||||
|
||||
## Search Syntax Examples
|
||||
|
||||
### Basic Searches
|
||||
- `search_notes("keyword")` - Find any content containing "keyword"
|
||||
- `search_notes("exact phrase")` - Search for exact phrase match
|
||||
|
||||
### Advanced Boolean Searches
|
||||
- `search_notes("term1 term2")` - Find content with both terms (implicit AND)
|
||||
- `search_notes("term1 AND term2")` - Explicit AND search (both terms required)
|
||||
- `search_notes("term1 OR term2")` - Either term can be present
|
||||
- `search_notes("term1 NOT term2")` - Include term1 but exclude term2
|
||||
- `search_notes("(project OR planning) AND notes")` - Grouped boolean logic
|
||||
|
||||
### Content-Specific Searches
|
||||
- `search_notes("tag:example")` - Search within specific tags (if supported by content)
|
||||
- `search_notes("category:observation")` - Filter by observation categories
|
||||
- `search_notes("author:username")` - Find content by author (if metadata available)
|
||||
|
||||
### Search Type Examples
|
||||
- `search_notes("Meeting", search_type="title")` - Search only in titles
|
||||
- `search_notes("docs/meeting-*", search_type="permalink")` - Pattern match permalinks
|
||||
- `search_notes("keyword", search_type="text")` - Full-text search (default)
|
||||
|
||||
### Filtering Options
|
||||
- `search_notes("query", types=["entity"])` - Search only entities
|
||||
- `search_notes("query", types=["note", "person"])` - Multiple content types
|
||||
- `search_notes("query", entity_types=["observation"])` - Filter by entity type
|
||||
- `search_notes("query", after_date="2024-01-01")` - Recent content only
|
||||
- `search_notes("query", after_date="1 week")` - Relative date filtering
|
||||
|
||||
### Advanced Pattern Examples
|
||||
- `search_notes("project AND (meeting OR discussion)")` - Complex boolean logic
|
||||
- `search_notes("\"exact phrase\" AND keyword")` - Combine phrase and keyword search
|
||||
- `search_notes("bug NOT fixed")` - Exclude resolved issues
|
||||
- `search_notes("docs/2024-*", search_type="permalink")` - Year-based permalink search
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
query: The search query string (supports boolean operators, phrases, patterns)
|
||||
page: The page number of results to return (default 1)
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text")
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d")
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
|
||||
project: Optional project name to search in. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
SearchResponse with results and pagination info
|
||||
SearchResponse with results and pagination info, or helpful error guidance if search fails
|
||||
|
||||
Examples:
|
||||
# Basic text search
|
||||
@@ -216,16 +283,19 @@ async def search_notes(
|
||||
# Boolean search with grouping
|
||||
results = await search_notes("(project OR planning) AND notes")
|
||||
|
||||
# Exact phrase search
|
||||
results = await search_notes("\"weekly standup meeting\"")
|
||||
|
||||
# Search with type filter
|
||||
results = await search_notes(
|
||||
query="meeting notes",
|
||||
types=["entity"],
|
||||
)
|
||||
|
||||
# Search with entity type filter, e.g., note vs
|
||||
# Search with entity type filter
|
||||
results = await search_notes(
|
||||
query="meeting notes",
|
||||
types=["entity"],
|
||||
entity_types=["observation"],
|
||||
)
|
||||
|
||||
# Search for recent content
|
||||
@@ -242,6 +312,13 @@ async def search_notes(
|
||||
|
||||
# Search in specific project
|
||||
results = await search_notes("meeting notes", project="work-project")
|
||||
|
||||
# Complex search with multiple filters
|
||||
results = await search_notes(
|
||||
query="(bug OR issue) AND NOT resolved",
|
||||
types=["entity"],
|
||||
after_date="2024-01-01"
|
||||
)
|
||||
"""
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
|
||||
@@ -4,8 +4,10 @@ from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
|
||||
|
||||
def _get_all_projects_status() -> list[str]:
|
||||
@@ -13,8 +15,7 @@ def _get_all_projects_status() -> list[str]:
|
||||
status_lines = []
|
||||
|
||||
try:
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
app_config = ConfigManager().config
|
||||
|
||||
if app_config.projects:
|
||||
status_lines.extend(["", "---", "", "**All Projects Status:**"])
|
||||
|
||||
@@ -525,11 +525,16 @@ def check_migration_status() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
|
||||
async def wait_for_migration_or_return_status(
|
||||
timeout: float = 5.0, project_name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Wait briefly for sync/migration to complete, or return status message.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for sync completion
|
||||
project_name: Optional project name to check specific project status.
|
||||
If provided, only checks that project's readiness.
|
||||
If None, uses global status check (legacy behavior).
|
||||
|
||||
Returns:
|
||||
Status message if sync is still in progress, None if ready
|
||||
@@ -538,18 +543,36 @@ async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[
|
||||
from basic_memory.services.sync_status_service import sync_status_tracker
|
||||
import asyncio
|
||||
|
||||
if sync_status_tracker.is_ready:
|
||||
# Check if we should use project-specific or global status
|
||||
def is_ready() -> bool:
|
||||
if project_name:
|
||||
return sync_status_tracker.is_project_ready(project_name)
|
||||
return sync_status_tracker.is_ready
|
||||
|
||||
if is_ready():
|
||||
return None
|
||||
|
||||
# Wait briefly for sync to complete
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while (asyncio.get_event_loop().time() - start_time) < timeout:
|
||||
if sync_status_tracker.is_ready:
|
||||
if is_ready():
|
||||
return None
|
||||
await asyncio.sleep(0.1) # Check every 100ms
|
||||
|
||||
# Still not ready after timeout
|
||||
return sync_status_tracker.get_summary()
|
||||
if project_name:
|
||||
# For project-specific checks, get project status details
|
||||
project_status = sync_status_tracker.get_project_status(project_name)
|
||||
if project_status and project_status.status.value == "failed":
|
||||
error_msg = project_status.error or "Unknown sync error"
|
||||
return f"❌ Sync failed for project '{project_name}': {error_msg}"
|
||||
elif project_status:
|
||||
return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
|
||||
else:
|
||||
return f"⚠️ Project '{project_name}' status unknown"
|
||||
else:
|
||||
# Fall back to global summary for legacy calls
|
||||
return sync_status_tracker.get_summary()
|
||||
except Exception: # pragma: no cover
|
||||
# If there's any error, assume ready
|
||||
return None
|
||||
|
||||
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import parse_tags
|
||||
from basic_memory.utils import parse_tags, validate_project_path
|
||||
|
||||
# Define TagType as a Union that can accept either a string or a list of strings or None
|
||||
TagType = Union[List[str], str, None]
|
||||
@@ -72,10 +72,23 @@ async def write_note(
|
||||
"""
|
||||
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
|
||||
|
||||
# Get the active project first to check project-specific sync status
|
||||
active_project = get_active_project(project)
|
||||
|
||||
# Validate folder path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if folder and not validate_project_path(folder, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
|
||||
)
|
||||
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Check migration status and wait briefly if needed
|
||||
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
||||
|
||||
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
||||
migration_status = await wait_for_migration_or_return_status(
|
||||
timeout=5.0, project_name=active_project.name
|
||||
)
|
||||
if migration_status: # pragma: no cover
|
||||
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
|
||||
|
||||
@@ -91,7 +104,6 @@ async def write_note(
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Create or update via knowledge API
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Project model for Basic Memory."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -52,9 +52,9 @@ class Project(Base):
|
||||
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
# Define relationships to entities, observations, and relations
|
||||
|
||||
@@ -142,7 +142,7 @@ class EntityRepository(Repository[Entity]):
|
||||
await session.flush()
|
||||
# Return with relationships loaded
|
||||
query = (
|
||||
select(Entity)
|
||||
self.select()
|
||||
.where(Entity.file_path == entity.file_path)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
@@ -162,7 +162,7 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
# Return with relationships loaded
|
||||
query = (
|
||||
select(Entity)
|
||||
self.select()
|
||||
.where(Entity.file_path == entity.file_path)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
@@ -203,7 +203,7 @@ class EntityRepository(Repository[Entity]):
|
||||
await session.flush()
|
||||
# Return the updated entity with relationships loaded
|
||||
query = (
|
||||
select(Entity)
|
||||
self.select()
|
||||
.where(Entity.file_path == entity.file_path)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
@@ -243,7 +243,7 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
# Return the inserted entity with relationships loaded
|
||||
query = (
|
||||
select(Entity)
|
||||
self.select()
|
||||
.where(Entity.file_path == entity.file_path)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
|
||||
@@ -83,3 +83,21 @@ class ProjectRepository(Repository[Project]):
|
||||
await session.flush()
|
||||
return target_project
|
||||
return None # pragma: no cover
|
||||
|
||||
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
|
||||
"""Update project path.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to update
|
||||
new_path: New filesystem path for the project
|
||||
|
||||
Returns:
|
||||
The updated project if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
project = await self.select_by_id(session, project_id)
|
||||
if project:
|
||||
project.path = new_path
|
||||
await session.flush()
|
||||
return project
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Repository for search operations."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -120,23 +121,141 @@ class SearchRepository:
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Prepare a Boolean query by quoting individual terms while preserving operators.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
|
||||
|
||||
Returns:
|
||||
A properly formatted Boolean query with quoted terms that need quoting
|
||||
"""
|
||||
# Define Boolean operators and their boundaries
|
||||
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
|
||||
|
||||
# Split the query by Boolean operators, keeping the operators
|
||||
parts = re.split(boolean_pattern, query)
|
||||
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# If it's a Boolean operator, keep it as is
|
||||
if part in ["AND", "OR", "NOT"]:
|
||||
processed_parts.append(part)
|
||||
else:
|
||||
# Handle parentheses specially - they should be preserved for grouping
|
||||
if "(" in part or ")" in part:
|
||||
# Parse parenthetical expressions carefully
|
||||
processed_part = self._prepare_parenthetical_term(part)
|
||||
processed_parts.append(processed_part)
|
||||
else:
|
||||
# This is a search term - for Boolean queries, don't add prefix wildcards
|
||||
prepared_term = self._prepare_single_term(part, is_prefix=False)
|
||||
processed_parts.append(prepared_term)
|
||||
|
||||
return " ".join(processed_parts)
|
||||
|
||||
def _prepare_parenthetical_term(self, term: str) -> str:
|
||||
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
|
||||
|
||||
Args:
|
||||
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
|
||||
|
||||
Returns:
|
||||
A properly formatted term with parentheses preserved
|
||||
"""
|
||||
# Handle terms that start/end with parentheses but may contain quotable content
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(term):
|
||||
if term[i] in "()":
|
||||
# Preserve parentheses as-is
|
||||
result += term[i]
|
||||
i += 1
|
||||
else:
|
||||
# Find the next parenthesis or end of string
|
||||
start = i
|
||||
while i < len(term) and term[i] not in "()":
|
||||
i += 1
|
||||
|
||||
# Extract the content between parentheses
|
||||
content = term[start:i].strip()
|
||||
if content:
|
||||
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
|
||||
# but don't quote if it's just simple words
|
||||
if self._needs_quoting(content):
|
||||
escaped_content = content.replace('"', '""')
|
||||
result += f'"{escaped_content}"'
|
||||
else:
|
||||
result += content
|
||||
|
||||
return result
|
||||
|
||||
def _needs_quoting(self, term: str) -> bool:
|
||||
"""Check if a term needs to be quoted for FTS5 safety.
|
||||
|
||||
Args:
|
||||
term: The term to check
|
||||
|
||||
Returns:
|
||||
True if the term should be quoted
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return False
|
||||
|
||||
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
|
||||
needs_quoting_chars = [
|
||||
" ",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"/",
|
||||
"-",
|
||||
"'",
|
||||
'"',
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
return any(c in term for c in needs_quoting_chars)
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term (no Boolean operators).
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
Returns:
|
||||
A properly formatted single term
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, return the term as is
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
@@ -218,6 +337,26 @@ class SearchRepository:
|
||||
|
||||
return term
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, process as Boolean query
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, use the single term preparation logic
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
@@ -242,19 +381,10 @@ class SearchRepository:
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Check for explicit boolean operators - only detect them in proper boolean contexts
|
||||
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
|
||||
|
||||
if has_boolean:
|
||||
# If boolean operators are present, use the raw query
|
||||
# No need to prepare it, FTS5 will understand the operators
|
||||
params["text"] = search_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
else:
|
||||
# Standard search with term preparation
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
|
||||
@@ -32,3 +32,4 @@ class EntityImportResult(ImportResult):
|
||||
|
||||
entities: int = 0
|
||||
relations: int = 0
|
||||
skipped_entities: int = 0
|
||||
|
||||
@@ -134,7 +134,7 @@ class RelationSummary(BaseModel):
|
||||
file_path: str
|
||||
permalink: str
|
||||
relation_type: str
|
||||
from_entity: str
|
||||
from_entity: Optional[str] = None
|
||||
to_entity: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
}
|
||||
"""
|
||||
|
||||
permalink: Permalink
|
||||
permalink: Optional[Permalink]
|
||||
title: str
|
||||
file_path: str
|
||||
entity_type: EntityType
|
||||
|
||||
@@ -15,6 +15,7 @@ from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.models import Observation, Relation
|
||||
from basic_memory.models.knowledge import Entity
|
||||
from basic_memory.repository import ObservationRepository, RelationRepository
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
@@ -44,6 +45,39 @@ class EntityService(BaseService[EntityModel]):
|
||||
self.file_service = file_service
|
||||
self.link_resolver = link_resolver
|
||||
|
||||
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
|
||||
"""Detect potential file path conflicts for a given file path.
|
||||
|
||||
This checks for entities with similar file paths that might cause conflicts:
|
||||
- Case sensitivity differences (Finance/file.md vs finance/file.md)
|
||||
- Character encoding differences
|
||||
- Hyphen vs space differences
|
||||
- Unicode normalization differences
|
||||
|
||||
Args:
|
||||
file_path: The file path to check for conflicts
|
||||
|
||||
Returns:
|
||||
List of entities that might conflict with the given file path
|
||||
"""
|
||||
from basic_memory.utils import detect_potential_file_conflicts
|
||||
|
||||
conflicts = []
|
||||
|
||||
# Get all existing file paths
|
||||
all_entities = await self.repository.find_all()
|
||||
existing_paths = [entity.file_path for entity in all_entities]
|
||||
|
||||
# Use the enhanced conflict detection utility
|
||||
conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
|
||||
|
||||
# Find the entities corresponding to conflicting paths
|
||||
for entity in all_entities:
|
||||
if entity.file_path in conflicting_paths:
|
||||
conflicts.append(entity)
|
||||
|
||||
return conflicts
|
||||
|
||||
async def resolve_permalink(
|
||||
self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
|
||||
) -> str:
|
||||
@@ -54,18 +88,30 @@ class EntityService(BaseService[EntityModel]):
|
||||
2. If markdown has permalink but it's used by another file -> make unique
|
||||
3. For existing files, keep current permalink from db
|
||||
4. Generate new unique permalink from file path
|
||||
|
||||
Enhanced to detect and handle character-related conflicts.
|
||||
"""
|
||||
file_path_str = str(file_path)
|
||||
|
||||
# Check for potential file path conflicts before resolving permalink
|
||||
conflicts = await self.detect_file_path_conflicts(file_path_str)
|
||||
if conflicts:
|
||||
logger.warning(
|
||||
f"Detected potential file path conflicts for '{file_path_str}': "
|
||||
f"{[entity.file_path for entity in conflicts]}"
|
||||
)
|
||||
|
||||
# If markdown has explicit permalink, try to validate it
|
||||
if markdown and markdown.frontmatter.permalink:
|
||||
desired_permalink = markdown.frontmatter.permalink
|
||||
existing = await self.repository.get_by_permalink(desired_permalink)
|
||||
|
||||
# If no conflict or it's our own file, use as is
|
||||
if not existing or existing.file_path == str(file_path):
|
||||
if not existing or existing.file_path == file_path_str:
|
||||
return desired_permalink
|
||||
|
||||
# For existing files, try to find current permalink
|
||||
existing = await self.repository.get_by_file_path(str(file_path))
|
||||
existing = await self.repository.get_by_file_path(file_path_str)
|
||||
if existing:
|
||||
return existing.permalink
|
||||
|
||||
@@ -75,7 +121,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
else:
|
||||
desired_permalink = generate_permalink(file_path)
|
||||
|
||||
# Make unique if needed
|
||||
# Make unique if needed - enhanced to handle character conflicts
|
||||
permalink = desired_permalink
|
||||
suffix = 1
|
||||
while await self.repository.get_by_permalink(permalink):
|
||||
@@ -682,8 +728,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
# 6. Prepare database updates
|
||||
updates = {"file_path": destination_path}
|
||||
|
||||
# 7. Update permalink if configured
|
||||
if app_config.update_permalinks_on_move:
|
||||
# 7. Update permalink if configured or if entity has null permalink
|
||||
if app_config.update_permalinks_on_move or old_permalink is None:
|
||||
# Generate new permalink from destination path
|
||||
new_permalink = await self.resolve_permalink(destination_path)
|
||||
|
||||
@@ -693,7 +739,12 @@ class EntityService(BaseService[EntityModel]):
|
||||
)
|
||||
|
||||
updates["permalink"] = new_permalink
|
||||
logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
|
||||
if old_permalink is None:
|
||||
logger.info(
|
||||
f"Generated permalink for entity with null permalink: {new_permalink}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
|
||||
|
||||
# 8. Recalculate checksum
|
||||
new_checksum = await self.file_service.compute_checksum(destination_path)
|
||||
|
||||
@@ -5,14 +5,12 @@ to ensure consistent application startup across all entry points.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository import ProjectRepository
|
||||
|
||||
|
||||
@@ -70,63 +68,6 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
||||
logger.info("Continuing with initialization despite synchronization error")
|
||||
|
||||
|
||||
async def migrate_legacy_projects(app_config: BasicMemoryConfig):
|
||||
# Get database session - migrations handled centrally
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
ensure_migrations=False,
|
||||
)
|
||||
logger.info("Migrating legacy projects...")
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# For each project in config.json, check if it has a .basic-memory dir
|
||||
for project_name, project_path in app_config.projects.items():
|
||||
legacy_dir = Path(project_path) / ".basic-memory"
|
||||
if not legacy_dir.exists():
|
||||
continue
|
||||
logger.info(f"Detected legacy project directory: {legacy_dir}")
|
||||
project = await project_repository.get_by_name(project_name)
|
||||
if not project: # pragma: no cover
|
||||
logger.error(f"Project {project_name} not found in database, skipping migration")
|
||||
continue
|
||||
|
||||
logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
|
||||
await migrate_legacy_project_data(project, legacy_dir)
|
||||
logger.info(f"Completed migration for project: {project_name}")
|
||||
logger.info("Legacy projects successfully migrated")
|
||||
|
||||
|
||||
async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> bool:
|
||||
"""Check if project has legacy .basic-memory dir and migrate if needed.
|
||||
|
||||
Args:
|
||||
project: The project to check and potentially migrate
|
||||
|
||||
Returns:
|
||||
True if migration occurred, False otherwise
|
||||
"""
|
||||
|
||||
# avoid circular imports
|
||||
from basic_memory.cli.commands.sync import get_sync_service
|
||||
|
||||
sync_service = await get_sync_service(project)
|
||||
sync_dir = Path(project.path)
|
||||
|
||||
logger.info(f"Sync starting project: {project.name}")
|
||||
await sync_service.sync(sync_dir, project_name=project.name)
|
||||
logger.info(f"Sync completed successfully for project: {project.name}")
|
||||
|
||||
# After successful sync, remove the legacy directory
|
||||
try:
|
||||
logger.info(f"Removing legacy directory: {legacy_dir}")
|
||||
shutil.rmtree(legacy_dir)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing legacy directory: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def initialize_file_sync(
|
||||
app_config: BasicMemoryConfig,
|
||||
):
|
||||
@@ -186,16 +127,6 @@ async def initialize_file_sync(
|
||||
sync_status_tracker.fail_project_sync(project.name, str(e))
|
||||
# Continue with other projects even if one fails
|
||||
|
||||
# Mark migration complete if it was in progress
|
||||
try:
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
if not migration_manager.is_ready: # pragma: no cover
|
||||
migration_manager.mark_completed("Migration completed with file sync")
|
||||
logger.info("Marked migration as completed after file sync")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Could not update migration status: {e}")
|
||||
|
||||
# Then start the watch service in the background
|
||||
logger.info("Starting watch service for all projects")
|
||||
# run the watch service
|
||||
@@ -229,13 +160,7 @@ async def initialize_app(
|
||||
# Reconcile projects from config.json with projects table
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
# Start background migration for legacy project data (non-blocking)
|
||||
from basic_memory.services.migration_service import migration_manager
|
||||
|
||||
await migration_manager.start_background_migration(app_config)
|
||||
|
||||
logger.info("App initialization completed (migration running in background if needed)")
|
||||
return migration_manager
|
||||
|
||||
|
||||
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Migration service for handling background migrations and status tracking."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class MigrationStatus(Enum):
|
||||
"""Status of migration operations."""
|
||||
|
||||
NOT_NEEDED = "not_needed"
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationState:
|
||||
"""Current state of migration operations."""
|
||||
|
||||
status: MigrationStatus
|
||||
message: str
|
||||
progress: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
projects_migrated: int = 0
|
||||
projects_total: int = 0
|
||||
|
||||
|
||||
class MigrationManager:
|
||||
"""Manages background migration operations and status tracking."""
|
||||
|
||||
def __init__(self):
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
||||
)
|
||||
self._migration_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def state(self) -> MigrationState:
|
||||
"""Get current migration state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""Check if the system is ready for normal operations."""
|
||||
return self._state.status in (MigrationStatus.NOT_NEEDED, MigrationStatus.COMPLETED)
|
||||
|
||||
@property
|
||||
def status_message(self) -> str:
|
||||
"""Get a user-friendly status message."""
|
||||
if self._state.status == MigrationStatus.IN_PROGRESS:
|
||||
progress = (
|
||||
f" ({self._state.projects_migrated}/{self._state.projects_total})"
|
||||
if self._state.projects_total > 0
|
||||
else ""
|
||||
)
|
||||
return f"🔄 File sync in progress{progress}: {self._state.message}. Use sync_status() tool for details."
|
||||
elif self._state.status == MigrationStatus.FAILED:
|
||||
return f"❌ File sync failed: {self._state.error or 'Unknown error'}. Use sync_status() tool for details."
|
||||
elif self._state.status == MigrationStatus.COMPLETED:
|
||||
return "✅ File sync completed successfully"
|
||||
else:
|
||||
return "✅ System ready"
|
||||
|
||||
async def check_migration_needed(self, app_config: BasicMemoryConfig) -> bool:
|
||||
"""Check if migration is needed without performing it."""
|
||||
from basic_memory import db
|
||||
from basic_memory.repository import ProjectRepository
|
||||
|
||||
try:
|
||||
# Get database session
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# Check for legacy projects
|
||||
legacy_projects = []
|
||||
for project_name, project_path in app_config.projects.items():
|
||||
legacy_dir = Path(project_path) / ".basic-memory"
|
||||
if legacy_dir.exists():
|
||||
project = await project_repository.get_by_name(project_name)
|
||||
if project:
|
||||
legacy_projects.append(project)
|
||||
|
||||
if legacy_projects:
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.PENDING,
|
||||
message="Legacy projects detected",
|
||||
projects_total=len(legacy_projects),
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.FAILED, message="Migration check failed", error=str(e)
|
||||
)
|
||||
return False
|
||||
|
||||
async def start_background_migration(self, app_config: BasicMemoryConfig) -> None:
|
||||
"""Start migration in background if needed."""
|
||||
if not await self.check_migration_needed(app_config):
|
||||
return
|
||||
|
||||
if self._migration_task and not self._migration_task.done():
|
||||
logger.info("Migration already in progress")
|
||||
return
|
||||
|
||||
logger.info("Starting background migration")
|
||||
self._migration_task = asyncio.create_task(self._run_migration(app_config))
|
||||
|
||||
async def _run_migration(self, app_config: BasicMemoryConfig) -> None:
|
||||
"""Run the actual migration process."""
|
||||
try:
|
||||
self._state.status = MigrationStatus.IN_PROGRESS
|
||||
self._state.message = "Migrating legacy projects"
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from basic_memory.services.initialization import migrate_legacy_projects
|
||||
|
||||
# Run the migration
|
||||
await migrate_legacy_projects(app_config)
|
||||
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.COMPLETED, message="Migration completed successfully"
|
||||
)
|
||||
logger.info("Background migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background migration failed: {e}")
|
||||
self._state = MigrationState(
|
||||
status=MigrationStatus.FAILED, message="Migration failed", error=str(e)
|
||||
)
|
||||
|
||||
async def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
|
||||
"""Wait for migration to complete."""
|
||||
if self.is_ready:
|
||||
return True
|
||||
|
||||
if not self._migration_task:
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._migration_task, timeout=timeout)
|
||||
return self.is_ready
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
def mark_completed(self, message: str = "Migration completed") -> None:
|
||||
"""Mark migration as completed externally."""
|
||||
self._state = MigrationState(status=MigrationStatus.COMPLETED, message=message)
|
||||
|
||||
|
||||
# Global migration manager instance
|
||||
migration_manager = MigrationManager()
|
||||
@@ -9,7 +9,6 @@ from typing import Dict, Optional, Sequence
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory.config import config, app_config
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.schemas import (
|
||||
@@ -18,9 +17,8 @@ from basic_memory.schemas import (
|
||||
ProjectStatistics,
|
||||
SystemStatus,
|
||||
)
|
||||
from basic_memory.config import WATCH_STATUS_JSON
|
||||
from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.config import config_manager
|
||||
|
||||
|
||||
class ProjectService:
|
||||
@@ -33,6 +31,24 @@ class ProjectService:
|
||||
super().__init__()
|
||||
self.repository = repository
|
||||
|
||||
@property
|
||||
def config_manager(self) -> ConfigManager:
|
||||
"""Get a ConfigManager instance.
|
||||
|
||||
Returns:
|
||||
Fresh ConfigManager instance for each access
|
||||
"""
|
||||
return ConfigManager()
|
||||
|
||||
@property
|
||||
def config(self) -> ProjectConfig:
|
||||
"""Get the current project configuration.
|
||||
|
||||
Returns:
|
||||
Current project configuration
|
||||
"""
|
||||
return get_project_config()
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
"""Get all configured projects.
|
||||
@@ -40,7 +56,7 @@ class ProjectService:
|
||||
Returns:
|
||||
Dict mapping project names to their file paths
|
||||
"""
|
||||
return config_manager.projects
|
||||
return self.config_manager.projects
|
||||
|
||||
@property
|
||||
def default_project(self) -> str:
|
||||
@@ -49,7 +65,7 @@ class ProjectService:
|
||||
Returns:
|
||||
The name of the default project
|
||||
"""
|
||||
return config_manager.default_project
|
||||
return self.config_manager.default_project
|
||||
|
||||
@property
|
||||
def current_project(self) -> str:
|
||||
@@ -58,7 +74,7 @@ class ProjectService:
|
||||
Returns:
|
||||
The name of the current project
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
|
||||
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
|
||||
|
||||
async def list_projects(self) -> Sequence[Project]:
|
||||
return await self.repository.find_all()
|
||||
@@ -87,7 +103,7 @@ class ProjectService:
|
||||
resolved_path = os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
# First add to config file (this will validate the project doesn't exist)
|
||||
project_config = config_manager.add_project(name, resolved_path)
|
||||
project_config = self.config_manager.add_project(name, resolved_path)
|
||||
|
||||
# Then add to database
|
||||
project_data = {
|
||||
@@ -103,7 +119,7 @@ class ProjectService:
|
||||
# If this should be the default project, ensure only one default exists
|
||||
if set_default:
|
||||
await self.repository.set_as_default(created_project.id)
|
||||
config_manager.set_default_project(name)
|
||||
self.config_manager.set_default_project(name)
|
||||
logger.info(f"Project '{name}' set as default")
|
||||
|
||||
logger.info(f"Project '{name}' added at {resolved_path}")
|
||||
@@ -121,7 +137,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for remove_project")
|
||||
|
||||
# First remove from config (this will validate the project exists and is not default)
|
||||
config_manager.remove_project(name)
|
||||
self.config_manager.remove_project(name)
|
||||
|
||||
# Then remove from database
|
||||
project = await self.repository.get_by_name(name)
|
||||
@@ -143,7 +159,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for set_default_project")
|
||||
|
||||
# First update config file (this will validate the project exists)
|
||||
config_manager.set_default_project(name)
|
||||
self.config_manager.set_default_project(name)
|
||||
|
||||
# Then update database
|
||||
project = await self.repository.get_by_name(name)
|
||||
@@ -196,7 +212,7 @@ class ProjectService:
|
||||
elif len(default_projects) == 0: # pragma: no cover
|
||||
# No default project - set the config default as default
|
||||
# This is defensive code for edge cases where no default exists
|
||||
config_default = config_manager.default_project # pragma: no cover
|
||||
config_default = self.config_manager.default_project # pragma: no cover
|
||||
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
|
||||
if config_project: # pragma: no cover
|
||||
await self.repository.set_as_default(config_project.id) # pragma: no cover
|
||||
@@ -221,7 +237,7 @@ class ProjectService:
|
||||
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
||||
|
||||
# Get all projects from configuration and normalize names if needed
|
||||
config_projects = config_manager.projects.copy()
|
||||
config_projects = self.config_manager.projects.copy()
|
||||
updated_config = {}
|
||||
config_updated = False
|
||||
|
||||
@@ -237,8 +253,9 @@ class ProjectService:
|
||||
|
||||
# Update the configuration if any changes were made
|
||||
if config_updated:
|
||||
config_manager.config.projects = updated_config
|
||||
config_manager.save_config(config_manager.config)
|
||||
config = self.config_manager.load_config()
|
||||
config.projects = updated_config
|
||||
self.config_manager.save_config(config)
|
||||
logger.info("Config updated with normalized project names")
|
||||
|
||||
# Use the normalized config for further processing
|
||||
@@ -261,19 +278,19 @@ class ProjectService:
|
||||
for name, project in db_projects_by_permalink.items():
|
||||
if name not in config_projects:
|
||||
logger.info(f"Adding project '{name}' to configuration")
|
||||
config_manager.add_project(name, project.path)
|
||||
self.config_manager.add_project(name, project.path)
|
||||
|
||||
# Ensure database default project state is consistent
|
||||
await self._ensure_single_default_project()
|
||||
|
||||
# Make sure default project is synchronized between config and database
|
||||
db_default = await self.repository.get_default_project()
|
||||
config_default = config_manager.default_project
|
||||
config_default = self.config_manager.default_project
|
||||
|
||||
if db_default and db_default.name != config_default:
|
||||
# Update config to match DB default
|
||||
logger.info(f"Updating default project in config to '{db_default.name}'")
|
||||
config_manager.set_default_project(db_default.name)
|
||||
self.config_manager.set_default_project(db_default.name)
|
||||
elif not db_default and config_default:
|
||||
# Update DB to match config default (if the project exists)
|
||||
project = await self.repository.get_by_name(config_default)
|
||||
@@ -292,6 +309,47 @@ class ProjectService:
|
||||
# MCP components might not be available in all contexts
|
||||
logger.debug("MCP session not available, skipping session refresh")
|
||||
|
||||
async def move_project(self, name: str, new_path: str) -> None:
|
||||
"""Move a project to a new location.
|
||||
|
||||
Args:
|
||||
name: The name of the project to move
|
||||
new_path: The new absolute path for the project
|
||||
|
||||
Raises:
|
||||
ValueError: If the project doesn't exist or repository isn't initialized
|
||||
"""
|
||||
if not self.repository:
|
||||
raise ValueError("Repository is required for move_project")
|
||||
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(new_path))
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in self.config_manager.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
# Create the new directory if it doesn't exist
|
||||
Path(resolved_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update in configuration
|
||||
config = self.config_manager.load_config()
|
||||
old_path = config.projects[name]
|
||||
config.projects[name] = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database
|
||||
project = await self.repository.get_by_name(name)
|
||||
if project:
|
||||
await self.repository.update_path(project.id, resolved_path)
|
||||
logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
|
||||
else:
|
||||
logger.error(f"Project '{name}' exists in config but not in database")
|
||||
# Restore the old path in config since DB update failed
|
||||
config.projects[name] = old_path
|
||||
self.config_manager.save_config(config)
|
||||
raise ValueError(f"Project '{name}' not found in database")
|
||||
|
||||
async def update_project( # pragma: no cover
|
||||
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
|
||||
) -> None:
|
||||
@@ -309,7 +367,7 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for update_project")
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in config_manager.projects:
|
||||
if name not in self.config_manager.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
# Get project from database
|
||||
@@ -323,10 +381,9 @@ class ProjectService:
|
||||
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
|
||||
|
||||
# Update in config
|
||||
projects = config_manager.config.projects.copy()
|
||||
projects[name] = resolved_path
|
||||
config_manager.config.projects = projects
|
||||
config_manager.save_config(config_manager.config)
|
||||
config = self.config_manager.load_config()
|
||||
config.projects[name] = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Update in database
|
||||
project.path = resolved_path
|
||||
@@ -347,7 +404,7 @@ class ProjectService:
|
||||
if active_projects:
|
||||
new_default = active_projects[0]
|
||||
await self.repository.set_as_default(new_default.id)
|
||||
config_manager.set_default_project(new_default.name)
|
||||
self.config_manager.set_default_project(new_default.name)
|
||||
logger.info(
|
||||
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
|
||||
)
|
||||
@@ -365,9 +422,9 @@ class ProjectService:
|
||||
raise ValueError("Repository is required for get_project_info")
|
||||
|
||||
# Use specified project or fall back to config project
|
||||
project_name = project_name or config.project
|
||||
project_name = project_name or self.config.project
|
||||
# Get project path from configuration
|
||||
name, project_path = config_manager.get_project(project_name)
|
||||
name, project_path = self.config_manager.get_project(project_name)
|
||||
if not name: # pragma: no cover
|
||||
raise ValueError(f"Project '{project_name}' not found in configuration")
|
||||
|
||||
@@ -393,11 +450,11 @@ class ProjectService:
|
||||
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
||||
|
||||
# Get default project info
|
||||
default_project = config_manager.default_project
|
||||
default_project = self.config_manager.default_project
|
||||
|
||||
# Convert config projects to include database info
|
||||
enhanced_projects = {}
|
||||
for name, path in config_manager.projects.items():
|
||||
for name, path in self.config_manager.projects.items():
|
||||
config_permalink = generate_permalink(name)
|
||||
db_project = db_projects_by_permalink.get(config_permalink)
|
||||
enhanced_projects[name] = {
|
||||
@@ -673,7 +730,7 @@ class ProjectService:
|
||||
import basic_memory
|
||||
|
||||
# Get database information
|
||||
db_path = app_config.database_path
|
||||
db_path = self.config_manager.config.database_path
|
||||
db_size = db_path.stat().st_size if db_path.exists() else 0
|
||||
db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
|
||||
|
||||
|
||||
@@ -131,6 +131,23 @@ class SyncStatusTracker:
|
||||
"""Check if system is ready (no sync in progress)."""
|
||||
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
|
||||
|
||||
def is_project_ready(self, project_name: str) -> bool:
|
||||
"""Check if a specific project is ready for operations.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to check
|
||||
|
||||
Returns:
|
||||
True if the project is ready (completed, watching, or not tracked),
|
||||
False if the project is syncing, scanning, or failed
|
||||
"""
|
||||
project_status = self._project_statuses.get(project_name)
|
||||
if not project_status:
|
||||
# Project not tracked = ready (likely hasn't been synced yet)
|
||||
return True
|
||||
|
||||
return project_status.status in (SyncStatus.COMPLETED, SyncStatus.WATCHING, SyncStatus.IDLE)
|
||||
|
||||
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
|
||||
"""Get status for a specific project."""
|
||||
return self._project_statuses.get(project_name)
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import config as project_config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.sync import SyncService, WatchService
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ async def sync_and_watch(
|
||||
): # pragma: no cover
|
||||
"""Run sync and watch service."""
|
||||
|
||||
logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
|
||||
config = get_project_config()
|
||||
logger.info(f"Starting watch service to sync file changes in dir: {config.home}")
|
||||
# full sync
|
||||
await sync_service.sync(project_config.home)
|
||||
await sync_service.sync(config.home)
|
||||
|
||||
# watch changes
|
||||
await watch_service.run()
|
||||
|
||||
@@ -453,6 +453,36 @@ class SyncService:
|
||||
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Check if destination path is already occupied by another entity
|
||||
existing_at_destination = await self.entity_repository.get_by_file_path(new_path)
|
||||
if existing_at_destination and existing_at_destination.id != entity.id:
|
||||
# Handle the conflict - this could be a file swap or replacement scenario
|
||||
logger.warning(
|
||||
f"File path conflict detected during move: "
|
||||
f"entity_id={entity.id} trying to move from '{old_path}' to '{new_path}', "
|
||||
f"but entity_id={existing_at_destination.id} already occupies '{new_path}'"
|
||||
)
|
||||
|
||||
# Check if this is a file swap (the destination entity is being moved to our old path)
|
||||
# This would indicate a simultaneous move operation
|
||||
old_path_after_swap = await self.entity_repository.get_by_file_path(old_path)
|
||||
if old_path_after_swap and old_path_after_swap.id == existing_at_destination.id:
|
||||
logger.info(f"Detected file swap between '{old_path}' and '{new_path}'")
|
||||
# This is a swap scenario - both moves should succeed
|
||||
# We'll allow this to proceed since the other file has moved out
|
||||
else:
|
||||
# This is a conflict where the destination is occupied
|
||||
raise ValueError(
|
||||
f"Cannot move entity from '{old_path}' to '{new_path}': "
|
||||
f"destination path is already occupied by another file. "
|
||||
f"This may be caused by: "
|
||||
f"1. Conflicting file names with different character encodings, "
|
||||
f"2. Case sensitivity differences (e.g., 'Finance/' vs 'finance/'), "
|
||||
f"3. Character conflicts between hyphens in filenames and generated permalinks, "
|
||||
f"4. Files with similar names containing special characters. "
|
||||
f"Try renaming one of the conflicting files to resolve this issue."
|
||||
)
|
||||
|
||||
# Update file_path in all cases
|
||||
updates = {"file_path": new_path}
|
||||
|
||||
@@ -477,7 +507,26 @@ class SyncService:
|
||||
f"new_checksum={new_checksum}"
|
||||
)
|
||||
|
||||
updated = await self.entity_repository.update(entity.id, updates)
|
||||
try:
|
||||
updated = await self.entity_repository.update(entity.id, updates)
|
||||
except Exception as e:
|
||||
# Catch any database integrity errors and provide helpful context
|
||||
if "UNIQUE constraint failed" in str(e):
|
||||
logger.error(
|
||||
f"Database constraint violation during move: "
|
||||
f"entity_id={entity.id}, old_path='{old_path}', new_path='{new_path}'"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Cannot complete move from '{old_path}' to '{new_path}': "
|
||||
f"a database constraint was violated. This usually indicates "
|
||||
f"a file path or permalink conflict. Please check for: "
|
||||
f"1. Duplicate file names, "
|
||||
f"2. Case sensitivity issues (e.g., 'File.md' vs 'file.md'), "
|
||||
f"3. Character encoding conflicts in file names."
|
||||
) from e
|
||||
else:
|
||||
# Re-raise other exceptions as-is
|
||||
raise
|
||||
|
||||
if updated is None: # pragma: no cover
|
||||
logger.error(
|
||||
|
||||
+107
-4
@@ -4,7 +4,6 @@ import os
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Optional, Protocol, Union, runtime_checkable, List, Any
|
||||
@@ -144,7 +143,7 @@ def setup_logging(
|
||||
console: Whether to log to the console
|
||||
"""
|
||||
# Remove default handler and any existing handlers
|
||||
logger.remove()
|
||||
# logger.remove()
|
||||
|
||||
# Add file handler if we are not running tests and a log file is specified
|
||||
if log_file and env != "test":
|
||||
@@ -162,8 +161,8 @@ def setup_logging(
|
||||
)
|
||||
|
||||
# Add console logger if requested or in test mode
|
||||
if env == "test" or console:
|
||||
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
|
||||
# if env == "test" or console:
|
||||
# logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
|
||||
|
||||
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
|
||||
|
||||
@@ -173,6 +172,8 @@ def setup_logging(
|
||||
"httpx": logging.WARNING,
|
||||
# File watching logs
|
||||
"watchfiles.main": logging.WARNING,
|
||||
# SQLAlchemy deprecation warnings
|
||||
"sqlalchemy": logging.WARNING,
|
||||
}
|
||||
|
||||
# Set log levels for noisy loggers
|
||||
@@ -180,6 +181,79 @@ def setup_logging(
|
||||
logging.getLogger(logger_name).setLevel(level)
|
||||
|
||||
|
||||
def normalize_file_path_for_comparison(file_path: str) -> str:
|
||||
"""Normalize a file path for conflict detection.
|
||||
|
||||
This function normalizes file paths to help detect potential conflicts:
|
||||
- Converts to lowercase for case-insensitive comparison
|
||||
- Normalizes Unicode characters
|
||||
- Handles path separators consistently
|
||||
|
||||
Args:
|
||||
file_path: The file path to normalize
|
||||
|
||||
Returns:
|
||||
Normalized file path for comparison purposes
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
# Convert to lowercase for case-insensitive comparison
|
||||
normalized = file_path.lower()
|
||||
|
||||
# Normalize Unicode characters (NFD normalization)
|
||||
normalized = unicodedata.normalize("NFD", normalized)
|
||||
|
||||
# Replace path separators with forward slashes
|
||||
normalized = normalized.replace("\\", "/")
|
||||
|
||||
# Remove multiple slashes
|
||||
normalized = re.sub(r"/+", "/", normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -> List[str]:
|
||||
"""Detect potential conflicts between a file path and existing paths.
|
||||
|
||||
This function checks for various types of conflicts:
|
||||
- Case sensitivity differences
|
||||
- Unicode normalization differences
|
||||
- Path separator differences
|
||||
- Permalink generation conflicts
|
||||
|
||||
Args:
|
||||
file_path: The file path to check
|
||||
existing_paths: List of existing file paths to check against
|
||||
|
||||
Returns:
|
||||
List of existing paths that might conflict with the given file path
|
||||
"""
|
||||
conflicts = []
|
||||
|
||||
# Normalize the input file path
|
||||
normalized_input = normalize_file_path_for_comparison(file_path)
|
||||
input_permalink = generate_permalink(file_path)
|
||||
|
||||
for existing_path in existing_paths:
|
||||
# Skip identical paths
|
||||
if existing_path == file_path:
|
||||
continue
|
||||
|
||||
# Check for case-insensitive path conflicts
|
||||
normalized_existing = normalize_file_path_for_comparison(existing_path)
|
||||
if normalized_input == normalized_existing:
|
||||
conflicts.append(existing_path)
|
||||
continue
|
||||
|
||||
# Check for permalink conflicts
|
||||
existing_permalink = generate_permalink(existing_path)
|
||||
if input_permalink == existing_permalink:
|
||||
conflicts.append(existing_path)
|
||||
continue
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
|
||||
"""Parse tags from various input formats into a consistent list.
|
||||
|
||||
@@ -212,3 +286,32 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
|
||||
except (ValueError, TypeError): # pragma: no cover
|
||||
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
|
||||
return []
|
||||
|
||||
|
||||
def validate_project_path(path: str, project_path: Path) -> bool:
|
||||
"""Ensure path stays within project boundaries."""
|
||||
# Allow empty strings as they resolve to the project root
|
||||
if not path:
|
||||
return True
|
||||
|
||||
# Check for obvious path traversal patterns first
|
||||
if ".." in path or "~" in path:
|
||||
return False
|
||||
|
||||
# Check for Windows-style path traversal (even on Unix systems)
|
||||
if "\\.." in path or path.startswith("\\"):
|
||||
return False
|
||||
|
||||
# Block absolute paths (Unix-style starting with / or Windows-style with drive letters)
|
||||
if path.startswith("/") or (len(path) >= 2 and path[1] == ":"):
|
||||
return False
|
||||
|
||||
# Block paths with control characters (but allow whitespace that will be stripped)
|
||||
if path.strip() and any(ord(c) < 32 and c not in [" ", "\t"] for c in path):
|
||||
return False
|
||||
|
||||
try:
|
||||
resolved = (project_path / path).resolve()
|
||||
return resolved.is_relative_to(project_path.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Integration test for database reset command.
|
||||
|
||||
This test validates the fix for GitHub issue #151 where the reset command
|
||||
was only removing the SQLite database but leaving project configuration
|
||||
intact in ~/.basic-memory/config.json.
|
||||
|
||||
The test verifies that the reset command now:
|
||||
1. Removes the SQLite database
|
||||
2. Resets project configuration to default state (main project only)
|
||||
3. Recreates empty database
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_config_file_behavior(config_manager):
|
||||
"""Test that reset command properly updates the config.json file."""
|
||||
|
||||
# Step 1: Set up initial state with multiple projects in config
|
||||
original_projects = {
|
||||
"project1": "/path/to/project1",
|
||||
"project2": "/path/to/project2",
|
||||
"user-project": "/home/user/documents",
|
||||
}
|
||||
config_manager.config.projects = original_projects.copy()
|
||||
config_manager.config.default_project = "user-project"
|
||||
|
||||
# Step 2: Save the config to a temporary file to simulate the real config file
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_config_file = Path(temp_dir) / "config.json"
|
||||
config_manager.config_file = temp_config_file
|
||||
config_manager.save_config(config_manager.config)
|
||||
|
||||
# Step 3: Verify the config file contains the multiple projects
|
||||
config_json = json.loads(temp_config_file.read_text())
|
||||
assert len(config_json["projects"]) == 3
|
||||
assert config_json["default_project"] == "user-project"
|
||||
assert "project1" in config_json["projects"]
|
||||
assert "project2" in config_json["projects"]
|
||||
assert "user-project" in config_json["projects"]
|
||||
|
||||
# Step 4: Simulate the reset command's configuration reset behavior
|
||||
# This is the exact fix for issue #151
|
||||
with patch("pathlib.Path.home") as mock_home:
|
||||
mock_home.return_value = Path("/home/testuser")
|
||||
|
||||
# Apply the reset logic from the reset command
|
||||
config_manager.config.projects = {"main": str(Path.home() / "basic-memory")}
|
||||
config_manager.config.default_project = "main"
|
||||
config_manager.save_config(config_manager.config)
|
||||
|
||||
# Step 5: Read the config file and verify it was properly reset
|
||||
updated_config_json = json.loads(temp_config_file.read_text())
|
||||
|
||||
# Should now only have the main project
|
||||
assert len(updated_config_json["projects"]) == 1
|
||||
assert "main" in updated_config_json["projects"]
|
||||
assert updated_config_json["projects"]["main"] == "/home/testuser/basic-memory"
|
||||
assert updated_config_json["default_project"] == "main"
|
||||
|
||||
# All original projects should be gone from the file
|
||||
assert "project1" not in updated_config_json["projects"]
|
||||
assert "project2" not in updated_config_json["projects"]
|
||||
assert "user-project" not in updated_config_json["projects"]
|
||||
|
||||
# This validates that issue #151 is fixed:
|
||||
# Before the fix, these projects would persist in config.json after reset
|
||||
# After the fix, only the default "main" project remains
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_command_source_code_validation():
|
||||
"""Validate that the reset command source contains the required fix."""
|
||||
# This test ensures the fix for issue #151 is present in the source code
|
||||
reset_source_path = (
|
||||
Path(__file__).parent.parent.parent / "src" / "basic_memory" / "cli" / "commands" / "db.py"
|
||||
)
|
||||
reset_source = reset_source_path.read_text()
|
||||
|
||||
# Verify the key components of the fix are present
|
||||
required_lines = [
|
||||
"# Reset project configuration",
|
||||
'config_manager.config.projects = {"main": str(Path.home() / "basic-memory")}',
|
||||
'config_manager.config.default_project = "main"',
|
||||
"config_manager.save_config(config_manager.config)",
|
||||
'logger.info("Project configuration reset to default")',
|
||||
]
|
||||
|
||||
for line in required_lines:
|
||||
assert line in reset_source, f"Required fix line not found: {line}"
|
||||
|
||||
# Verify the fix is in the correct location (after database deletion, before recreation)
|
||||
lines = reset_source.split("\n")
|
||||
|
||||
# Find key markers
|
||||
db_deletion_line = None
|
||||
config_reset_line = None
|
||||
db_recreation_line = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "db_path.unlink()" in line:
|
||||
db_deletion_line = i
|
||||
elif "config_manager.config.projects = {" in line:
|
||||
config_reset_line = i
|
||||
elif "asyncio.run(db.run_migrations" in line:
|
||||
db_recreation_line = i
|
||||
|
||||
# Verify the order is correct
|
||||
assert db_deletion_line is not None, "Database deletion code not found"
|
||||
assert config_reset_line is not None, "Config reset code not found"
|
||||
assert db_recreation_line is not None, "Database recreation code not found"
|
||||
|
||||
# Config reset should be after db deletion and before db recreation
|
||||
assert db_deletion_line < config_reset_line < db_recreation_line, (
|
||||
"Config reset is not in the correct order in the reset command"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_reset_behavior_simulation(config_manager):
|
||||
"""Test the specific configuration reset behavior that fixes issue #151."""
|
||||
|
||||
# Step 1: Set up the problem state (multiple projects in config)
|
||||
original_projects = {
|
||||
"project1": "/path/to/project1",
|
||||
"project2": "/path/to/project2",
|
||||
"user-project": "/home/user/documents",
|
||||
}
|
||||
config_manager.config.projects = original_projects.copy()
|
||||
config_manager.config.default_project = "user-project"
|
||||
|
||||
# Verify the problem state
|
||||
assert len(config_manager.config.projects) == 3
|
||||
assert config_manager.config.default_project == "user-project"
|
||||
|
||||
# Step 2: Apply the reset fix (simulate what reset command does)
|
||||
with patch("pathlib.Path.home") as mock_home:
|
||||
mock_home.return_value = Path("/home/testuser")
|
||||
|
||||
# This is the exact code from the reset command that fixes issue #151
|
||||
config_manager.config.projects = {"main": str(Path.home() / "basic-memory")}
|
||||
config_manager.config.default_project = "main"
|
||||
# Note: We don't call save_config in test to avoid file operations
|
||||
|
||||
# Step 3: Verify the fix worked
|
||||
assert len(config_manager.config.projects) == 1
|
||||
assert "main" in config_manager.config.projects
|
||||
assert config_manager.config.projects["main"] == "/home/testuser/basic-memory"
|
||||
assert config_manager.config.default_project == "main"
|
||||
|
||||
# Step 4: Verify original projects are gone
|
||||
for project_name in original_projects:
|
||||
assert project_name not in config_manager.config.projects
|
||||
+27
-40
@@ -58,16 +58,12 @@ from pathlib import Path
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
import basic_memory.config
|
||||
import basic_memory.mcp.project_session
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from fastapi import FastAPI
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
|
||||
|
||||
|
||||
@@ -93,12 +89,12 @@ async def engine_factory(tmp_path):
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_project(tmp_path, engine_factory) -> Project:
|
||||
async def test_project(config_home, engine_factory) -> Project:
|
||||
"""Create a test project."""
|
||||
project_data = {
|
||||
"name": "test-project",
|
||||
"description": "Project used for integration tests",
|
||||
"path": str(tmp_path),
|
||||
"path": str(config_home),
|
||||
"is_active": True,
|
||||
"is_default": True,
|
||||
}
|
||||
@@ -112,55 +108,50 @@ async def test_project(tmp_path, engine_factory) -> Project:
|
||||
@pytest.fixture
|
||||
def config_home(tmp_path, monkeypatch) -> Path:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# Set BASIC_MEMORY_HOME to the test directory
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def app_config(config_home, test_project, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
"""Create test app configuration."""
|
||||
projects = {test_project.name: str(test_project.path)}
|
||||
# Create a basic config with test-project like unit tests do
|
||||
projects = {"test-project": str(config_home)}
|
||||
app_config = BasicMemoryConfig(
|
||||
env="test",
|
||||
projects=projects,
|
||||
default_project=test_project.name,
|
||||
default_project="test-project",
|
||||
update_permalinks_on_move=True,
|
||||
)
|
||||
|
||||
# Set the module app_config instance project list (like regular tests)
|
||||
monkeypatch.setattr("basic_memory.config.app_config", app_config)
|
||||
return app_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> ConfigManager:
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
|
||||
config_manager = ConfigManager()
|
||||
# Update its paths to use the test directory
|
||||
config_manager.config_dir = config_home / ".basic-memory"
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override the config directly instead of relying on disk load
|
||||
config_manager.config = app_config
|
||||
|
||||
# Ensure the config file is written to disk
|
||||
config_manager.save_config(app_config)
|
||||
|
||||
# Patch the config_manager in all locations where it's imported
|
||||
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
|
||||
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
|
||||
monkeypatch.setattr("basic_memory.mcp.project_session.config_manager", config_manager)
|
||||
|
||||
return config_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_session(test_project: Project):
|
||||
# initialize the project session with the test project
|
||||
basic_memory.mcp.project_session.session.initialize(test_project.name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def project_config(test_project, monkeypatch):
|
||||
def project_session(test_project: Project, config_manager):
|
||||
# Initialize the global session directly with the test project
|
||||
# This ensures all MCP tools use the test project context
|
||||
import basic_memory.mcp.project_session
|
||||
|
||||
basic_memory.mcp.project_session.session.initialize(test_project.name)
|
||||
return basic_memory.mcp.project_session.session
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def project_config(test_project):
|
||||
"""Create test project configuration."""
|
||||
|
||||
project_config = ProjectConfig(
|
||||
@@ -168,9 +159,6 @@ def project_config(test_project, monkeypatch):
|
||||
home=Path(test_project.path),
|
||||
)
|
||||
|
||||
# override config module project config
|
||||
monkeypatch.setattr("basic_memory.config.config", project_config)
|
||||
|
||||
return project_config
|
||||
|
||||
|
||||
@@ -180,6 +168,10 @@ def app(
|
||||
) -> FastAPI:
|
||||
"""Create test FastAPI application with single project."""
|
||||
|
||||
# Import the FastAPI app AFTER the config_manager has written the test config to disk
|
||||
# This ensures that when the app's lifespan manager runs, it reads the correct test config
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
app = fastapi_app
|
||||
app.dependency_overrides[get_project_config] = lambda: project_config
|
||||
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
|
||||
@@ -215,7 +207,7 @@ async def search_service(engine_factory, test_project):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mcp_server(app_config, search_service):
|
||||
def mcp_server(config_manager, search_service, project_session):
|
||||
# Import mcp instance
|
||||
from basic_memory.mcp.server import mcp as server
|
||||
|
||||
@@ -225,11 +217,6 @@ def mcp_server(app_config, search_service):
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401
|
||||
|
||||
# Initialize project session with test project
|
||||
from basic_memory.mcp.project_session import session
|
||||
|
||||
session.initialize(app_config.default_project)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ async def test_build_context_valid_urls(mcp_server, app):
|
||||
result = await client.call_tool("build_context", {"url": url})
|
||||
|
||||
# Should return a valid GraphContext response
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
assert len(result.content) == 1
|
||||
response = result.content[0].text
|
||||
assert '"results"' in response # Should contain results structure
|
||||
assert '"metadata"' in response # Should contain metadata
|
||||
|
||||
@@ -82,6 +82,7 @@ async def test_build_context_empty_urls_fail_validation(mcp_server, app):
|
||||
or "too_short" in error_message
|
||||
or "empty or whitespace" in error_message
|
||||
or "value_error" in error_message
|
||||
or "should be non-empty" in error_message
|
||||
)
|
||||
|
||||
|
||||
@@ -101,8 +102,8 @@ async def test_build_context_nonexistent_urls_return_empty_results(mcp_server, a
|
||||
result = await client.call_tool("build_context", {"url": url})
|
||||
|
||||
# Should return valid response with empty results
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
assert len(result.content) == 1
|
||||
response = result.content[0].text
|
||||
assert '"results": []' in response # Empty results
|
||||
assert '"total_results": 0' in response # Zero count
|
||||
assert '"metadata"' in response # But should have metadata
|
||||
@@ -163,8 +164,8 @@ async def test_build_context_pattern_matching_works(mcp_server, app):
|
||||
# Test pattern matching
|
||||
result = await client.call_tool("build_context", {"url": "patterns/*"})
|
||||
|
||||
assert len(result) == 1
|
||||
response = result[0].text
|
||||
assert len(result.content) == 1
|
||||
response = result.content[0].text
|
||||
|
||||
# Should find the pattern matches but not the other note
|
||||
assert '"total_results": 2' in response or '"primary_count": 2' in response
|
||||
|
||||
@@ -31,8 +31,8 @@ async def test_delete_note_by_title(mcp_server, app):
|
||||
"identifier": "Note to Delete",
|
||||
},
|
||||
)
|
||||
assert len(read_result) == 1
|
||||
assert "Note to Delete" in read_result[0].text
|
||||
assert len(read_result.content) == 1
|
||||
assert "Note to Delete" in read_result.content[0].text
|
||||
|
||||
# Delete the note by title
|
||||
delete_result = await client.call_tool(
|
||||
@@ -43,9 +43,9 @@ async def test_delete_note_by_title(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert len(delete_result) == 1
|
||||
assert delete_result[0].type == "text"
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert len(delete_result.content) == 1
|
||||
assert delete_result.content[0].type == "text"
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify the note no longer exists
|
||||
read_after_delete = await client.call_tool(
|
||||
@@ -56,8 +56,8 @@ async def test_delete_note_by_title(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return helpful "Note Not Found" message instead of the actual note
|
||||
assert len(read_after_delete) == 1
|
||||
result_text = read_after_delete[0].text
|
||||
assert len(read_after_delete.content) == 1
|
||||
result_text = read_after_delete.content[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert "Note to Delete" in result_text
|
||||
|
||||
@@ -87,8 +87,8 @@ async def test_delete_note_by_permalink(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert len(delete_result) == 1
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert len(delete_result.content) == 1
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify the note no longer exists by searching
|
||||
search_result = await client.call_tool(
|
||||
@@ -99,7 +99,10 @@ async def test_delete_note_by_permalink(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should have no results
|
||||
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
|
||||
assert (
|
||||
'"results": []' in search_result.content[0].text
|
||||
or '"results":[]' in search_result.content[0].text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -142,8 +145,8 @@ The system handles multiple projects and users."""
|
||||
"identifier": "Project Management System",
|
||||
},
|
||||
)
|
||||
assert len(read_result) == 1
|
||||
result_text = read_result[0].text
|
||||
assert len(read_result.content) == 1
|
||||
result_text = read_result.content[0].text
|
||||
assert "Task tracking functionality" in result_text
|
||||
assert "depends_on" in result_text
|
||||
|
||||
@@ -156,7 +159,7 @@ The system handles multiple projects and users."""
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify the note and all its components are deleted
|
||||
read_after_delete_2 = await client.call_tool(
|
||||
@@ -167,8 +170,8 @@ The system handles multiple projects and users."""
|
||||
)
|
||||
|
||||
# Should return "Note Not Found" message
|
||||
assert len(read_after_delete_2) == 1
|
||||
result_text = read_after_delete_2[0].text
|
||||
assert len(read_after_delete_2.content) == 1
|
||||
result_text = read_after_delete_2.content[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert "Project Management System" in result_text
|
||||
|
||||
@@ -209,7 +212,9 @@ async def test_delete_note_special_characters_in_title(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result[0].text.lower(), f"Failed to delete note: {title}"
|
||||
assert "true" in delete_result.content[0].text.lower(), (
|
||||
f"Failed to delete note: {title}"
|
||||
)
|
||||
|
||||
# Verify the note is deleted
|
||||
read_after_delete = await client.call_tool(
|
||||
@@ -220,8 +225,8 @@ async def test_delete_note_special_characters_in_title(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return "Note Not Found" message
|
||||
assert len(read_after_delete) == 1
|
||||
result_text = read_after_delete[0].text
|
||||
assert len(read_after_delete.content) == 1
|
||||
result_text = read_after_delete.content[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert title in result_text
|
||||
|
||||
@@ -240,8 +245,8 @@ async def test_delete_nonexistent_note(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return False for unsuccessful deletion
|
||||
assert len(delete_result) == 1
|
||||
assert "false" in delete_result[0].text.lower()
|
||||
assert len(delete_result.content) == 1
|
||||
assert "false" in delete_result.content[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -269,7 +274,7 @@ async def test_delete_note_by_file_path(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify deletion
|
||||
read_after_delete = await client.call_tool(
|
||||
@@ -280,8 +285,8 @@ async def test_delete_note_by_file_path(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return "Note Not Found" message
|
||||
assert len(read_after_delete) == 1
|
||||
result_text = read_after_delete[0].text
|
||||
assert len(read_after_delete.content) == 1
|
||||
result_text = read_after_delete.content[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert "File Path Delete" in result_text
|
||||
|
||||
@@ -311,7 +316,7 @@ async def test_delete_note_case_insensitive(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -349,7 +354,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app):
|
||||
)
|
||||
|
||||
# Each deletion should be successful
|
||||
assert "true" in delete_result[0].text.lower(), f"Failed to delete {title}"
|
||||
assert "true" in delete_result.content[0].text.lower(), f"Failed to delete {title}"
|
||||
|
||||
# Verify all notes are deleted by searching
|
||||
search_result = await client.call_tool(
|
||||
@@ -360,7 +365,10 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should have no results
|
||||
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
|
||||
assert (
|
||||
'"results": []' in search_result.content[0].text
|
||||
or '"results":[]' in search_result.content[0].text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -404,7 +412,7 @@ This note contains various Unicode characters:
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result[0].text.lower()
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify deletion
|
||||
read_after_delete = await client.call_tool(
|
||||
@@ -415,7 +423,7 @@ This note contains various Unicode characters:
|
||||
)
|
||||
|
||||
# Should return "Note Not Found" message
|
||||
assert len(read_after_delete) == 1
|
||||
result_text = read_after_delete[0].text
|
||||
assert len(read_after_delete.content) == 1
|
||||
result_text = read_after_delete.content[0].text
|
||||
assert "Note Not Found" in result_text
|
||||
assert "Unicode Test Note" in result_text
|
||||
|
||||
@@ -35,8 +35,8 @@ async def test_edit_note_append_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return successful edit summary
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (append)" in edit_text
|
||||
assert "Added 5 lines to end of note" in edit_text
|
||||
assert "test/append-test-note" in edit_text
|
||||
@@ -49,7 +49,7 @@ async def test_edit_note_append_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "Original content here." in content
|
||||
assert "## New Section" in content
|
||||
assert "This content was appended." in content
|
||||
@@ -82,8 +82,8 @@ async def test_edit_note_prepend_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return successful edit summary
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (prepend)" in edit_text
|
||||
assert "Added 5 lines to beginning of note" in edit_text
|
||||
|
||||
@@ -95,7 +95,7 @@ async def test_edit_note_prepend_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "## Important Update" in content
|
||||
assert "This was added at the top." in content
|
||||
assert "Existing content." in content
|
||||
@@ -143,8 +143,8 @@ v1.0.0 introduces new features.""",
|
||||
)
|
||||
|
||||
# Should return successful edit summary
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (find_replace)" in edit_text
|
||||
assert "Find and replace operation completed" in edit_text
|
||||
|
||||
@@ -156,7 +156,7 @@ v1.0.0 introduces new features.""",
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "v1.2.0" in content
|
||||
assert "v1.0.0" not in content # Should be completely replaced
|
||||
assert content.count("v1.2.0") == 3 # Should have exactly 3 occurrences
|
||||
@@ -206,8 +206,8 @@ All services communicate via message queues.""",
|
||||
)
|
||||
|
||||
# Should return successful edit summary
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (replace_section)" in edit_text
|
||||
assert "Replaced content under section '## Implementation'" in edit_text
|
||||
|
||||
@@ -219,7 +219,7 @@ All services communicate via message queues.""",
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "New implementation approach using microservices" in content
|
||||
assert "Old implementation details here" not in content
|
||||
assert "Service A handles authentication" in content
|
||||
@@ -282,8 +282,8 @@ Current endpoints include user management."""
|
||||
)
|
||||
|
||||
# Should return edit summary with observation and relation counts
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (append)" in edit_text
|
||||
assert "## Observations" in edit_text
|
||||
assert "## Relations" in edit_text
|
||||
@@ -301,7 +301,7 @@ Current endpoints include user management."""
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "Added payment processing endpoints" in content
|
||||
assert "integrates_with [[Payment Gateway]]" in content
|
||||
|
||||
@@ -322,8 +322,8 @@ async def test_edit_note_error_handling_note_not_found(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return helpful error message
|
||||
assert len(edit_result) == 1
|
||||
error_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
error_text = edit_result.content[0].text
|
||||
assert "Edit Failed" in error_text
|
||||
assert "Non-existent Note" in error_text
|
||||
assert "search_notes(" in error_text
|
||||
@@ -357,8 +357,8 @@ async def test_edit_note_error_handling_text_not_found(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return helpful error message
|
||||
assert len(edit_result) == 1
|
||||
error_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
error_text = edit_result.content[0].text
|
||||
assert "Edit Failed - Text Not Found" in error_text
|
||||
assert "non-existent text" in error_text
|
||||
assert "Error Test Note" in error_text
|
||||
@@ -398,8 +398,8 @@ Final test of the content.""",
|
||||
)
|
||||
|
||||
# Should return helpful error message about count mismatch
|
||||
assert len(edit_result) == 1
|
||||
error_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
error_text = edit_result.content[0].text
|
||||
assert "Edit Failed - Wrong Replacement Count" in error_text
|
||||
assert "Expected 5 occurrences" in error_text
|
||||
assert "test" in error_text
|
||||
@@ -521,8 +521,8 @@ def test_function():
|
||||
)
|
||||
|
||||
# Should successfully handle special characters
|
||||
assert len(edit_result) == 1
|
||||
edit_text = edit_result[0].text
|
||||
assert len(edit_result.content) == 1
|
||||
edit_text = edit_result.content[0].text
|
||||
assert "Edited note (append)" in edit_text
|
||||
assert "## Observations" in edit_text
|
||||
assert "unicode:" in edit_text
|
||||
@@ -536,7 +536,7 @@ def test_function():
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "🚀" in content
|
||||
assert "测试中文" in content
|
||||
assert "∑∏∂∇∆Ω" in content
|
||||
@@ -569,7 +569,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app):
|
||||
"content": "\n\nEdited by title.",
|
||||
},
|
||||
)
|
||||
assert "Edited note (append)" in edit_result1[0].text
|
||||
assert "Edited note (append)" in edit_result1.content[0].text
|
||||
|
||||
# Test editing by permalink
|
||||
edit_result2 = await client.call_tool(
|
||||
@@ -580,7 +580,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app):
|
||||
"content": "\n\nEdited by permalink.",
|
||||
},
|
||||
)
|
||||
assert "Edited note (append)" in edit_result2[0].text
|
||||
assert "Edited note (append)" in edit_result2.content[0].text
|
||||
|
||||
# Test editing by folder/title format
|
||||
edit_result3 = await client.call_tool(
|
||||
@@ -591,7 +591,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app):
|
||||
"content": "\n\nEdited by folder/title.",
|
||||
},
|
||||
)
|
||||
assert "Edited note (append)" in edit_result3[0].text
|
||||
assert "Edited note (append)" in edit_result3.content[0].text
|
||||
|
||||
# Verify all edits were applied
|
||||
read_result = await client.call_tool(
|
||||
@@ -601,7 +601,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "Edited by title." in content
|
||||
assert "Edited by permalink." in content
|
||||
assert "Edited by folder/title." in content
|
||||
|
||||
@@ -54,8 +54,8 @@ async def test_list_directory_basic_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return formatted directory listing
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show the structure
|
||||
assert "Contents of '/' (depth 1):" in list_text
|
||||
@@ -113,8 +113,8 @@ async def test_list_directory_specific_folder(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show work folder contents
|
||||
assert "Contents of '/work' (depth 1):" in list_text
|
||||
@@ -169,8 +169,8 @@ async def test_list_directory_with_depth(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show nested structure within depth=3
|
||||
assert "Contents of '/research' (depth 3):" in list_text
|
||||
@@ -226,8 +226,8 @@ async def test_list_directory_with_glob_pattern(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show only matching files
|
||||
assert "Files in '/meetings' matching 'Meeting*' (depth 1):" in list_text
|
||||
@@ -250,8 +250,8 @@ async def test_list_directory_empty_directory(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should indicate no files found
|
||||
assert "No files found in directory '/empty'" in list_text
|
||||
@@ -283,8 +283,8 @@ async def test_list_directory_glob_no_matches(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should indicate no matches for the pattern
|
||||
assert "No files found in directory '/docs' matching '*.py'" in list_text
|
||||
@@ -325,8 +325,8 @@ async def test_list_directory_various_file_types(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show file names, paths, and titles
|
||||
assert "📄 Simple Note.md" in list_text
|
||||
@@ -358,8 +358,8 @@ async def test_list_directory_default_parameters(mcp_server, app):
|
||||
{}, # Use all defaults
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show root directory with depth 1
|
||||
assert "Contents of '/' (depth 1):" in list_text
|
||||
@@ -402,8 +402,8 @@ async def test_list_directory_deep_recursion(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show deep structure
|
||||
assert "Contents of '/level1' (depth 10):" in list_text
|
||||
@@ -457,8 +457,8 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show only Project files
|
||||
assert "Project Alpha Plan.md" in list_text
|
||||
|
||||
@@ -34,8 +34,8 @@ async def test_move_note_basic_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return successful move message
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "Move Test Note" in move_text
|
||||
assert "destination/moved-note.md" in move_text
|
||||
@@ -49,7 +49,7 @@ async def test_move_note_basic_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "This note will be moved to a new location" in content
|
||||
|
||||
# Verify the original location no longer works
|
||||
@@ -61,7 +61,7 @@ async def test_move_note_basic_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return "Note Not Found" message
|
||||
assert "Note Not Found" in read_original[0].text
|
||||
assert "Note Not Found" in read_original.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,8 +90,8 @@ async def test_move_note_using_permalink(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should successfully move
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "test/permalink-move-test" in move_text
|
||||
assert "archive/permalink-moved.md" in move_text
|
||||
@@ -104,7 +104,7 @@ async def test_move_note_using_permalink(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert "Moving by permalink" in read_result[0].text
|
||||
assert "Moving by permalink" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -150,8 +150,8 @@ This note demonstrates moving complex content."""
|
||||
)
|
||||
|
||||
# Should successfully move
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "Complex Note" in move_text
|
||||
assert "moved/complex-note.md" in move_text
|
||||
@@ -164,7 +164,7 @@ This note demonstrates moving complex content."""
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result[0].text
|
||||
content = read_result.content[0].text
|
||||
assert "Has structured observations" in content
|
||||
assert "implements [[Auth System]]" in content
|
||||
assert "## Observations" in content
|
||||
@@ -198,8 +198,8 @@ async def test_move_note_to_nested_directory(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should successfully create directory structure and move
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "Nested Move Test" in move_text
|
||||
assert "projects/2025/q2/work/nested-note.md" in move_text
|
||||
@@ -212,7 +212,7 @@ async def test_move_note_to_nested_directory(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert "This will be moved deep" in read_result[0].text
|
||||
assert "This will be moved deep" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -241,8 +241,8 @@ async def test_move_note_with_special_characters(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should handle special characters properly
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "archive/special-chars-note.md" in move_text
|
||||
|
||||
@@ -254,7 +254,7 @@ async def test_move_note_with_special_characters(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert "Testing special characters in move" in read_result[0].text
|
||||
assert "Testing special characters in move" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -272,8 +272,8 @@ async def test_move_note_error_handling_note_not_found(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
error_message = move_result.content[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "Non-existent Note" in error_message
|
||||
|
||||
@@ -304,8 +304,8 @@ async def test_move_note_error_handling_invalid_destination(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
error_message = move_result.content[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "/absolute/path/note.md" in error_message
|
||||
|
||||
@@ -347,8 +347,8 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should contain error message about the failed operation
|
||||
assert len(move_result) == 1
|
||||
error_message = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
error_message = move_result.content[0].text
|
||||
assert "# Move Failed" in error_message
|
||||
assert "already exists" in error_message
|
||||
|
||||
@@ -389,8 +389,8 @@ This note contains unique search terms:
|
||||
},
|
||||
)
|
||||
|
||||
assert len(search_before) > 0
|
||||
assert "Searchable Note" in search_before[0].text
|
||||
assert len(search_before.content) > 0
|
||||
assert "Searchable Note" in search_before.content[0].text
|
||||
|
||||
# Move the note
|
||||
move_result = await client.call_tool(
|
||||
@@ -401,8 +401,8 @@ This note contains unique search terms:
|
||||
},
|
||||
)
|
||||
|
||||
assert len(move_result) == 1
|
||||
move_text = move_result[0].text
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
|
||||
# Verify note is still searchable after move
|
||||
@@ -413,8 +413,8 @@ This note contains unique search terms:
|
||||
},
|
||||
)
|
||||
|
||||
assert len(search_after) > 0
|
||||
search_text = search_after[0].text
|
||||
assert len(search_after.content) > 0
|
||||
search_text = search_after.content[0].text
|
||||
assert "quantum mechanics" in search_text
|
||||
assert "research/quantum-ai-note.md" in search_text or "quantum-ai-note" in search_text
|
||||
|
||||
@@ -426,7 +426,7 @@ This note contains unique search terms:
|
||||
},
|
||||
)
|
||||
|
||||
assert len(search_by_path) > 0
|
||||
assert len(search_by_path.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -473,8 +473,8 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app):
|
||||
"destination_path": "moved/title-moved.md",
|
||||
},
|
||||
)
|
||||
assert len(move1) == 1
|
||||
assert "✅ Note moved successfully" in move1[0].text
|
||||
assert len(move1.content) == 1
|
||||
assert "✅ Note moved successfully" in move1.content[0].text
|
||||
|
||||
# Test moving by permalink
|
||||
move2 = await client.call_tool(
|
||||
@@ -484,8 +484,8 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app):
|
||||
"destination_path": "moved/permalink-moved.md",
|
||||
},
|
||||
)
|
||||
assert len(move2) == 1
|
||||
assert "✅ Note moved successfully" in move2[0].text
|
||||
assert len(move2.content) == 1
|
||||
assert "✅ Note moved successfully" in move2.content[0].text
|
||||
|
||||
# Test moving by folder/title format
|
||||
move3 = await client.call_tool(
|
||||
@@ -495,15 +495,104 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app):
|
||||
"destination_path": "moved/folder-title-moved.md",
|
||||
},
|
||||
)
|
||||
assert len(move3) == 1
|
||||
assert "✅ Note moved successfully" in move3[0].text
|
||||
assert len(move3.content) == 1
|
||||
assert "✅ Note moved successfully" in move3.content[0].text
|
||||
|
||||
# Verify all notes can be accessed at their new locations
|
||||
read1 = await client.call_tool("read_note", {"identifier": "moved/title-moved.md"})
|
||||
assert "Move by title" in read1[0].text
|
||||
assert "Move by title" in read1.content[0].text
|
||||
|
||||
read2 = await client.call_tool("read_note", {"identifier": "moved/permalink-moved.md"})
|
||||
assert "Move by permalink" in read2[0].text
|
||||
assert "Move by permalink" in read2.content[0].text
|
||||
|
||||
read3 = await client.call_tool("read_note", {"identifier": "moved/folder-title-moved.md"})
|
||||
assert "Move by folder/title" in read3[0].text
|
||||
assert "Move by folder/title" in read3.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_cross_project_detection(mcp_server, app):
|
||||
"""Test cross-project move detection and helpful error messages."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a test project to simulate cross-project scenario
|
||||
await client.call_tool(
|
||||
"create_memory_project",
|
||||
{
|
||||
"project_name": "test-project-b",
|
||||
"project_path": "/tmp/test-project-b",
|
||||
"set_default": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Create a note in the default project
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Cross Project Test Note",
|
||||
"folder": "source",
|
||||
"content": "# Cross Project Test Note\n\nThis note is in the default project.",
|
||||
"tags": "test,cross-project",
|
||||
},
|
||||
)
|
||||
|
||||
# Try to move to a path that contains the other project name
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Cross Project Test Note",
|
||||
"destination_path": "test-project-b/moved-note.md",
|
||||
},
|
||||
)
|
||||
|
||||
# Should detect cross-project attempt and provide helpful guidance
|
||||
assert len(move_result.content) == 1
|
||||
error_message = move_result.content[0].text
|
||||
assert "Cross-Project Move Not Supported" in error_message
|
||||
assert "test-project-b" in error_message
|
||||
assert "switch_project" in error_message
|
||||
assert "read_note" in error_message
|
||||
assert "write_note" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_normal_moves_still_work(mcp_server, app):
|
||||
"""Test that normal within-project moves still work after cross-project detection."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a note
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Normal Move Note",
|
||||
"folder": "source",
|
||||
"content": "# Normal Move Note\n\nThis should move normally.",
|
||||
"tags": "test,normal-move",
|
||||
},
|
||||
)
|
||||
|
||||
# Try a normal move that should work
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Normal Move Note",
|
||||
"destination_path": "destination/normal-moved.md",
|
||||
},
|
||||
)
|
||||
|
||||
# Should work normally
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "✅ Note moved successfully" in move_text
|
||||
assert "Normal Move Note" in move_text
|
||||
assert "destination/normal-moved.md" in move_text
|
||||
|
||||
# Verify the note can be read from its new location
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"identifier": "destination/normal-moved.md",
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result.content[0].text
|
||||
assert "This should move normally" in content
|
||||
|
||||
@@ -20,8 +20,8 @@ async def test_list_projects_basic_operation(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should return formatted project list
|
||||
assert len(list_result) == 1
|
||||
list_text = list_result[0].text
|
||||
assert len(list_result.content) == 1
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
# Should show available projects with status indicators
|
||||
assert "Available projects:" in list_text
|
||||
@@ -52,8 +52,8 @@ async def test_get_current_project_operation(mcp_server, app):
|
||||
{},
|
||||
)
|
||||
|
||||
assert len(current_result) == 1
|
||||
current_text = current_result[0].text
|
||||
assert len(current_result.content) == 1
|
||||
current_text = current_result.content[0].text
|
||||
|
||||
# Should show current project and stats
|
||||
assert "Current project: test-project" in current_text
|
||||
@@ -114,8 +114,8 @@ This is the second entity.
|
||||
{},
|
||||
)
|
||||
|
||||
assert len(current_result) == 1
|
||||
current_text = current_result[0].text
|
||||
assert len(current_result.content) == 1
|
||||
current_text = current_result.content[0].text
|
||||
|
||||
# Should show entity and observation counts
|
||||
assert "Current project: test-project" in current_text
|
||||
@@ -144,8 +144,8 @@ async def test_switch_project_not_found(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(switch_result) == 1
|
||||
switch_text = switch_result[0].text
|
||||
assert len(switch_result.content) == 1
|
||||
switch_text = switch_result.content[0].text
|
||||
|
||||
# Should show error message with available projects
|
||||
assert "Error: Project 'non-existent-project' not found" in switch_text
|
||||
@@ -166,8 +166,8 @@ async def test_switch_project_to_test_project(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(switch_result) == 1
|
||||
switch_text = switch_result[0].text
|
||||
assert len(switch_result.content) == 1
|
||||
switch_text = switch_result.content[0].text
|
||||
|
||||
# Should show successful switch
|
||||
assert "✓ Switched to test-project project" in switch_text
|
||||
@@ -189,8 +189,8 @@ async def test_set_default_project_operation(mcp_server, app):
|
||||
{},
|
||||
)
|
||||
|
||||
assert len(current_result) == 1
|
||||
current_text = current_result[0].text
|
||||
assert len(current_result.content) == 1
|
||||
current_text = current_result.content[0].text
|
||||
|
||||
# Should show current project and stats
|
||||
assert "Current project: test-project" in current_text
|
||||
@@ -203,8 +203,8 @@ async def test_set_default_project_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(default_result) == 1
|
||||
default_text = default_result[0].text
|
||||
assert len(default_result.content) == 1
|
||||
default_text = default_result.content[0].text
|
||||
|
||||
# Should show success message and restart instructions
|
||||
assert "✓" in default_text # Success indicator
|
||||
@@ -245,20 +245,20 @@ async def test_project_management_workflow(mcp_server, app):
|
||||
async with Client(mcp_server) as client:
|
||||
# 1. Check current project
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
assert "test-project" in current_result[0].text
|
||||
assert "test-project" in current_result.content[0].text
|
||||
|
||||
# 2. List all projects
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert "Available projects:" in list_result[0].text
|
||||
assert "test-project" in list_result[0].text
|
||||
assert "Available projects:" in list_result.content[0].text
|
||||
assert "test-project" in list_result.content[0].text
|
||||
|
||||
# 3. Switch to same project (should work)
|
||||
switch_result = await client.call_tool("switch_project", {"project_name": "test-project"})
|
||||
assert "✓ Switched to test-project project" in switch_result[0].text
|
||||
assert "✓ Switched to test-project project" in switch_result.content[0].text
|
||||
|
||||
# 4. Verify we're still on the same project
|
||||
current_result2 = await client.call_tool("get_current_project", {})
|
||||
assert "Current project: test-project" in current_result2[0].text
|
||||
assert "Current project: test-project" in current_result2.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -270,22 +270,22 @@ async def test_project_metadata_consistency(mcp_server, app):
|
||||
|
||||
# list_projects
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert "Project: test-project" in list_result[0].text
|
||||
assert "Project: test-project" in list_result.content[0].text
|
||||
|
||||
# get_current_project
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
assert "Project: test-project" in current_result[0].text
|
||||
assert "Project: test-project" in current_result.content[0].text
|
||||
|
||||
# switch_project
|
||||
switch_result = await client.call_tool("switch_project", {"project_name": "test-project"})
|
||||
assert "Project: test-project" in switch_result[0].text
|
||||
assert "Project: test-project" in switch_result.content[0].text
|
||||
|
||||
# set_default_project (skip since API not working in test env)
|
||||
# default_result = await client.call_tool(
|
||||
# "set_default_project",
|
||||
# {"project_name": "test-project"}
|
||||
# )
|
||||
# assert "Project: test-project" in default_result[0].text
|
||||
# assert "Project: test-project" in default_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -295,7 +295,7 @@ async def test_project_statistics_accuracy(mcp_server, app):
|
||||
async with Client(mcp_server) as client:
|
||||
# Get initial stats
|
||||
initial_result = await client.call_tool("get_current_project", {})
|
||||
initial_text = initial_result[0].text
|
||||
initial_text = initial_result.content[0].text
|
||||
assert initial_text is not None
|
||||
|
||||
# Create a new entity
|
||||
@@ -320,7 +320,7 @@ Testing statistics accuracy.
|
||||
|
||||
# Get updated stats
|
||||
updated_result = await client.call_tool("get_current_project", {})
|
||||
updated_text = updated_result[0].text
|
||||
updated_text = updated_result.content[0].text
|
||||
|
||||
# Should show project info with stats
|
||||
assert "Current project: test-project" in updated_text
|
||||
@@ -357,8 +357,8 @@ async def test_create_project_basic_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(create_result) == 1
|
||||
create_text = create_result[0].text
|
||||
assert len(create_result.content) == 1
|
||||
create_text = create_result.content[0].text
|
||||
|
||||
# Should show success message and project details
|
||||
assert "✓" in create_text # Success indicator
|
||||
@@ -371,7 +371,7 @@ async def test_create_project_basic_operation(mcp_server, app):
|
||||
|
||||
# Verify project appears in project list
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
list_text = list_result[0].text
|
||||
list_text = list_result.content[0].text
|
||||
assert "test-new-project" in list_text
|
||||
|
||||
|
||||
@@ -390,8 +390,8 @@ async def test_create_project_with_default_flag(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(create_result) == 1
|
||||
create_text = create_result[0].text
|
||||
assert len(create_result.content) == 1
|
||||
create_text = create_result.content[0].text
|
||||
|
||||
# Should show success and default flag
|
||||
assert "✓" in create_text
|
||||
@@ -401,7 +401,7 @@ async def test_create_project_with_default_flag(mcp_server, app):
|
||||
|
||||
# Verify we switched to the new project
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
current_text = current_result[0].text
|
||||
current_text = current_result.content[0].text
|
||||
assert "Current project: test-default-project" in current_text
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ async def test_delete_project_basic_operation(mcp_server, app):
|
||||
|
||||
# Verify it exists
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert "to-be-deleted" in list_result[0].text
|
||||
assert "to-be-deleted" in list_result.content[0].text
|
||||
|
||||
# Delete the project
|
||||
delete_result = await client.call_tool(
|
||||
@@ -465,8 +465,8 @@ async def test_delete_project_basic_operation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(delete_result) == 1
|
||||
delete_text = delete_result[0].text
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
|
||||
# Should show success message
|
||||
assert "✓" in delete_text
|
||||
@@ -479,7 +479,7 @@ async def test_delete_project_basic_operation(mcp_server, app):
|
||||
|
||||
# Verify project no longer appears in list
|
||||
list_result_after = await client.call_tool("list_memory_projects", {})
|
||||
assert "to-be-deleted" not in list_result_after[0].text
|
||||
assert "to-be-deleted" not in list_result_after.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -546,8 +546,8 @@ async def test_project_lifecycle_workflow(mcp_server, app):
|
||||
"project_path": project_path,
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result[0].text
|
||||
assert project_name in create_result[0].text
|
||||
assert "✓" in create_result.content[0].text
|
||||
assert project_name in create_result.content[0].text
|
||||
|
||||
# 2. Switch to the new project
|
||||
switch_result = await client.call_tool(
|
||||
@@ -556,7 +556,7 @@ async def test_project_lifecycle_workflow(mcp_server, app):
|
||||
"project_name": project_name,
|
||||
},
|
||||
)
|
||||
assert f"✓ Switched to {project_name} project" in switch_result[0].text
|
||||
assert f"✓ Switched to {project_name} project" in switch_result.content[0].text
|
||||
|
||||
# 3. Create content in the new project
|
||||
await client.call_tool(
|
||||
@@ -571,7 +571,7 @@ async def test_project_lifecycle_workflow(mcp_server, app):
|
||||
|
||||
# 4. Verify project stats show our content
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
current_text = current_result[0].text
|
||||
current_text = current_result.content[0].text
|
||||
assert f"Current project: {project_name}" in current_text
|
||||
assert "entities" in current_text
|
||||
|
||||
@@ -590,13 +590,13 @@ async def test_project_lifecycle_workflow(mcp_server, app):
|
||||
"project_name": project_name,
|
||||
},
|
||||
)
|
||||
assert "✓" in delete_result[0].text
|
||||
assert f"{project_name}" in delete_result[0].text
|
||||
assert "removed successfully" in delete_result[0].text
|
||||
assert "✓" in delete_result.content[0].text
|
||||
assert f"{project_name}" in delete_result.content[0].text
|
||||
assert "removed successfully" in delete_result.content[0].text
|
||||
|
||||
# 7. Verify project is gone from list
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert project_name not in list_result[0].text
|
||||
assert project_name not in list_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -615,12 +615,12 @@ async def test_create_delete_project_edge_cases(mcp_server, app):
|
||||
"project_path": f"/tmp/{special_name}",
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result[0].text
|
||||
assert special_name in create_result[0].text
|
||||
assert "✓" in create_result.content[0].text
|
||||
assert special_name in create_result.content[0].text
|
||||
|
||||
# Verify it appears in list
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert special_name in list_result[0].text
|
||||
assert special_name in list_result.content[0].text
|
||||
|
||||
# Delete it
|
||||
delete_result = await client.call_tool(
|
||||
@@ -629,12 +629,12 @@ async def test_create_delete_project_edge_cases(mcp_server, app):
|
||||
"project_name": special_name,
|
||||
},
|
||||
)
|
||||
assert "✓" in delete_result[0].text
|
||||
assert special_name in delete_result[0].text
|
||||
assert "✓" in delete_result.content[0].text
|
||||
assert special_name in delete_result.content[0].text
|
||||
|
||||
# Verify it's gone
|
||||
list_result_after = await client.call_tool("list_memory_projects", {})
|
||||
assert special_name not in list_result_after[0].text
|
||||
assert special_name not in list_result_after.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -651,12 +651,12 @@ async def test_case_insensitive_project_switching(mcp_server, app):
|
||||
"project_path": f"/tmp/{project_name}",
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result[0].text
|
||||
assert project_name in create_result[0].text
|
||||
assert "✓" in create_result.content[0].text
|
||||
assert project_name in create_result.content[0].text
|
||||
|
||||
# Verify project was created with canonical name
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
assert project_name in list_result[0].text
|
||||
assert project_name in list_result.content[0].text
|
||||
|
||||
# Test switching with different case variations
|
||||
test_cases = [
|
||||
@@ -674,18 +674,18 @@ async def test_case_insensitive_project_switching(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should succeed and show canonical name in response
|
||||
assert "✓ Switched to" in switch_result[0].text
|
||||
assert project_name in switch_result[0].text # Canonical name should appear
|
||||
assert "✓ Switched to" in switch_result.content[0].text
|
||||
assert project_name in switch_result.content[0].text # Canonical name should appear
|
||||
# Project summary may be unavailable in test environment
|
||||
assert (
|
||||
"Project Summary:" in switch_result[0].text
|
||||
or "Project summary unavailable" in switch_result[0].text
|
||||
"Project Summary:" in switch_result.content[0].text
|
||||
or "Project summary unavailable" in switch_result.content[0].text
|
||||
)
|
||||
|
||||
# Verify get_current_project works after case-insensitive switch
|
||||
try:
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
current_text = current_result[0].text
|
||||
current_text = current_result.content[0].text
|
||||
|
||||
# Should show canonical project name, not the input case
|
||||
assert f"Current project: {project_name}" in current_text
|
||||
@@ -715,15 +715,15 @@ async def test_case_insensitive_project_operations(mcp_server, app):
|
||||
"project_path": f"/tmp/{project_name}",
|
||||
},
|
||||
)
|
||||
assert "✓" in create_result[0].text
|
||||
assert "✓" in create_result.content[0].text
|
||||
|
||||
# Switch to project using lowercase input
|
||||
switch_result = await client.call_tool(
|
||||
"switch_project",
|
||||
{"project_name": "camel-case-project"}, # lowercase input
|
||||
)
|
||||
assert "✓ Switched to" in switch_result[0].text
|
||||
assert project_name in switch_result[0].text # Should show canonical name
|
||||
assert "✓ Switched to" in switch_result.content[0].text
|
||||
assert project_name in switch_result.content[0].text # Should show canonical name
|
||||
|
||||
# Test that MCP operations work correctly after case-insensitive switch
|
||||
|
||||
@@ -737,12 +737,12 @@ async def test_case_insensitive_project_operations(mcp_server, app):
|
||||
"tags": "case,test",
|
||||
},
|
||||
)
|
||||
assert len(write_result) == 1
|
||||
assert "Case Test Note" in write_result[0].text
|
||||
assert len(write_result.content) == 1
|
||||
assert "Case Test Note" in write_result.content[0].text
|
||||
|
||||
# 2. Verify get_current_project shows stats correctly
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
current_text = current_result[0].text
|
||||
current_text = current_result.content[0].text
|
||||
assert f"Current project: {project_name}" in current_text
|
||||
assert "1 entities" in current_text or "entities" in current_text
|
||||
|
||||
@@ -751,17 +751,17 @@ async def test_case_insensitive_project_operations(mcp_server, app):
|
||||
"search_notes",
|
||||
{"query": "case insensitive"},
|
||||
)
|
||||
assert len(search_result) == 1
|
||||
assert "Case Test Note" in search_result[0].text
|
||||
assert len(search_result.content) == 1
|
||||
assert "Case Test Note" in search_result.content[0].text
|
||||
|
||||
# 4. Test read_note works
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{"identifier": "Case Test Note"},
|
||||
)
|
||||
assert len(read_result) == 1
|
||||
assert "Case Test Note" in read_result[0].text
|
||||
assert "case insensitive" in read_result[0].text.lower()
|
||||
assert len(read_result.content) == 1
|
||||
assert "Case Test Note" in read_result.content[0].text
|
||||
assert "case insensitive" in read_result.content[0].text.lower()
|
||||
|
||||
# Clean up
|
||||
await client.call_tool("switch_project", {"project_name": "test-project"})
|
||||
@@ -788,9 +788,9 @@ async def test_case_insensitive_error_handling(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should show error for all case variations
|
||||
assert f"Error: Project '{test_case}' not found" in switch_result[0].text
|
||||
assert "Available projects:" in switch_result[0].text
|
||||
assert "test-project" in switch_result[0].text
|
||||
assert f"Error: Project '{test_case}' not found" in switch_result.content[0].text
|
||||
assert "Available projects:" in switch_result.content[0].text
|
||||
assert "test-project" in switch_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -818,7 +818,7 @@ async def test_case_preservation_in_project_list(mcp_server, app):
|
||||
|
||||
# List projects and verify each appears with its original case
|
||||
list_result = await client.call_tool("list_memory_projects", {})
|
||||
list_text = list_result[0].text
|
||||
list_text = list_result.content[0].text
|
||||
|
||||
for project_name in test_projects:
|
||||
assert project_name in list_text, f"Project {project_name} not found in list"
|
||||
@@ -833,12 +833,12 @@ async def test_case_preservation_in_project_list(mcp_server, app):
|
||||
)
|
||||
|
||||
# Should succeed and show original case in response
|
||||
assert "✓ Switched to" in switch_result[0].text
|
||||
assert project_name in switch_result[0].text # Original case preserved
|
||||
assert "✓ Switched to" in switch_result.content[0].text
|
||||
assert project_name in switch_result.content[0].text # Original case preserved
|
||||
|
||||
# Verify current project shows original case
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
assert f"Current project: {project_name}" in current_result[0].text
|
||||
assert f"Current project: {project_name}" in current_result.content[0].text
|
||||
|
||||
# Clean up - switch back and delete test projects
|
||||
await client.call_tool("switch_project", {"project_name": "test-project"})
|
||||
@@ -888,14 +888,17 @@ async def test_session_state_consistency_after_case_switch(mcp_server, app):
|
||||
|
||||
# All operations should work and reference the canonical project name
|
||||
if op_name == "get_current_project":
|
||||
assert f"Current project: {project_name}" in result[0].text
|
||||
assert f"Current project: {project_name}" in result.content[0].text
|
||||
elif op_name == "list_memory_projects":
|
||||
assert project_name in result[0].text
|
||||
assert "(current)" in result[0].text or "current" in result[0].text.lower()
|
||||
assert project_name in result.content[0].text
|
||||
assert (
|
||||
"(current)" in result.content[0].text
|
||||
or "current" in result.content[0].text.lower()
|
||||
)
|
||||
|
||||
# All operations should include project metadata with canonical name
|
||||
# FIXME
|
||||
# assert f"Project: {project_name}" in result[0].text
|
||||
# assert f"Project: {project_name}" in result.content[0].text
|
||||
|
||||
# Clean up
|
||||
await client.call_tool("switch_project", {"project_name": "test-project"})
|
||||
|
||||
@@ -21,8 +21,8 @@ async def test_project_state_sync_after_default_change(mcp_server, app, config_m
|
||||
async with Client(mcp_server) as client:
|
||||
# Step 1: Verify initial state - MCP should show test-project as current
|
||||
initial_result = await client.call_tool("get_current_project", {})
|
||||
assert len(initial_result) == 1
|
||||
assert "Current project: test-project" in initial_result[0].text
|
||||
assert len(initial_result.content) == 1
|
||||
assert "Current project: test-project" in initial_result.content[0].text
|
||||
|
||||
# Step 2: Create a second project that we can switch to
|
||||
create_result = await client.call_tool(
|
||||
@@ -33,26 +33,26 @@ async def test_project_state_sync_after_default_change(mcp_server, app, config_m
|
||||
"set_default": False, # Don't set as default yet
|
||||
},
|
||||
)
|
||||
assert len(create_result) == 1
|
||||
assert "✓" in create_result[0].text
|
||||
assert "minerva" in create_result[0].text
|
||||
assert len(create_result.content) == 1
|
||||
assert "✓" in create_result.content[0].text
|
||||
assert "minerva" in create_result.content[0].text
|
||||
|
||||
# Step 3: Change default project to minerva via set_default_project tool
|
||||
# This simulates the CLI command `basic-memory project default minerva`
|
||||
set_default_result = await client.call_tool(
|
||||
"set_default_project", {"project_name": "minerva"}
|
||||
)
|
||||
assert len(set_default_result) == 1
|
||||
assert "✓" in set_default_result[0].text
|
||||
assert "minerva" in set_default_result[0].text
|
||||
assert len(set_default_result.content) == 1
|
||||
assert "✓" in set_default_result.content[0].text
|
||||
assert "minerva" in set_default_result.content[0].text
|
||||
|
||||
# Step 4: Verify MCP session immediately reflects the change (no restart needed)
|
||||
# This tests the fix - session.refresh_from_config() should have been called
|
||||
updated_result = await client.call_tool("get_current_project", {})
|
||||
assert len(updated_result) == 1
|
||||
assert len(updated_result.content) == 1
|
||||
|
||||
# The fix should ensure these are consistent now:
|
||||
updated_text = updated_result[0].text
|
||||
updated_text = updated_result.content[0].text
|
||||
assert "Current project: minerva" in updated_text
|
||||
|
||||
# Step 5: Verify config manager also shows the new default
|
||||
@@ -69,14 +69,14 @@ async def test_project_state_sync_after_default_change(mcp_server, app, config_m
|
||||
"tags": "test,consistency",
|
||||
},
|
||||
)
|
||||
assert len(write_result) == 1
|
||||
assert "Test Consistency Note" in write_result[0].text
|
||||
assert len(write_result.content) == 1
|
||||
assert "Test Consistency Note" in write_result.content[0].text
|
||||
|
||||
# Step 7: Test that we can read the note we just created
|
||||
read_result = await client.call_tool("read_note", {"identifier": "Test Consistency Note"})
|
||||
assert len(read_result) == 1
|
||||
assert "Test Consistency Note" in read_result[0].text
|
||||
assert "project state sync working" in read_result[0].text.lower()
|
||||
assert len(read_result.content) == 1
|
||||
assert "Test Consistency Note" in read_result.content[0].text
|
||||
assert "project state sync working" in read_result.content[0].text.lower()
|
||||
|
||||
# Step 8: Test that edit operations work (this was failing in the original issue)
|
||||
edit_result = await client.call_tool(
|
||||
@@ -87,15 +87,18 @@ async def test_project_state_sync_after_default_change(mcp_server, app, config_m
|
||||
"content": "\n\n## Update\n\nEdit operation successful after project switch!",
|
||||
},
|
||||
)
|
||||
assert len(edit_result) == 1
|
||||
assert "added" in edit_result[0].text.lower() and "lines" in edit_result[0].text.lower()
|
||||
assert len(edit_result.content) == 1
|
||||
assert (
|
||||
"added" in edit_result.content[0].text.lower()
|
||||
and "lines" in edit_result.content[0].text.lower()
|
||||
)
|
||||
|
||||
# Step 9: Verify the edit was applied
|
||||
final_read_result = await client.call_tool(
|
||||
"read_note", {"identifier": "Test Consistency Note"}
|
||||
)
|
||||
assert len(final_read_result) == 1
|
||||
final_content = final_read_result[0].text
|
||||
assert len(final_read_result.content) == 1
|
||||
final_content = final_read_result.content[0].text
|
||||
assert "Edit operation successful" in final_content
|
||||
|
||||
# Clean up - switch back to test-project
|
||||
@@ -124,11 +127,11 @@ async def test_multiple_project_switches_maintain_consistency(mcp_server, app, c
|
||||
set_result = await client.call_tool(
|
||||
"set_default_project", {"project_name": project_name}
|
||||
)
|
||||
assert "✓" in set_result[0].text
|
||||
assert "✓" in set_result.content[0].text
|
||||
|
||||
# Verify MCP session immediately reflects the change
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
assert f"Current project: {project_name}" in current_result[0].text
|
||||
assert f"Current project: {project_name}" in current_result.content[0].text
|
||||
|
||||
# Verify config is also updated
|
||||
assert config_manager.default_project == project_name
|
||||
@@ -144,7 +147,7 @@ async def test_multiple_project_switches_maintain_consistency(mcp_server, app, c
|
||||
"tags": "test",
|
||||
},
|
||||
)
|
||||
assert note_title in write_result[0].text
|
||||
assert note_title in write_result.content[0].text
|
||||
|
||||
# Clean up - switch back to test-project
|
||||
await client.call_tool("set_default_project", {"project_name": "test-project"})
|
||||
@@ -159,8 +162,8 @@ async def test_session_handles_nonexistent_project_gracefully(mcp_server, app):
|
||||
switch_result = await client.call_tool(
|
||||
"switch_project", {"project_name": "nonexistent-project"}
|
||||
)
|
||||
assert len(switch_result) == 1
|
||||
result_text = switch_result[0].text
|
||||
assert len(switch_result.content) == 1
|
||||
result_text = switch_result.content[0].text
|
||||
|
||||
# Should show an error message
|
||||
assert "Error:" in result_text
|
||||
@@ -170,4 +173,4 @@ async def test_session_handles_nonexistent_project_gracefully(mcp_server, app):
|
||||
|
||||
# Verify the session stays on the original project
|
||||
current_result = await client.call_tool("get_current_project", {})
|
||||
assert "Current project: test-project" in current_result[0].text
|
||||
assert "Current project: test-project" in current_result.content[0].text
|
||||
|
||||
@@ -13,9 +13,9 @@ from fastmcp.exceptions import ToolError
|
||||
|
||||
def parse_read_content_response(mcp_result):
|
||||
"""Helper function to parse read_content MCP response."""
|
||||
assert len(mcp_result) == 1
|
||||
assert mcp_result[0].type == "text"
|
||||
return json.loads(mcp_result[0].text)
|
||||
assert len(mcp_result.content) == 1
|
||||
assert mcp_result.content[0].type == "text"
|
||||
return json.loads(mcp_result.content[0].text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -359,9 +359,9 @@ async def test_read_content_special_characters_in_filename(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(read_result) == 1
|
||||
assert read_result[0].type == "text"
|
||||
content = read_result[0].text
|
||||
assert len(read_result.content) == 1
|
||||
assert read_result.content[0].type == "text"
|
||||
content = read_result.content[0].text
|
||||
|
||||
assert f"# {title}" in content
|
||||
assert f"Content for {title}" in content
|
||||
|
||||
@@ -24,9 +24,9 @@ async def test_read_note_after_write(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(write_result) == 1
|
||||
assert write_result[0].type == "text"
|
||||
assert "Test Note.md" in write_result[0].text
|
||||
assert len(write_result.content) == 1
|
||||
assert write_result.content[0].type == "text"
|
||||
assert "Test Note.md" in write_result.content[0].text
|
||||
|
||||
# Then read it back
|
||||
read_result = await client.call_tool(
|
||||
@@ -36,9 +36,9 @@ async def test_read_note_after_write(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(read_result) == 1
|
||||
assert read_result[0].type == "text"
|
||||
result_text = read_result[0].text
|
||||
assert len(read_result.content) == 1
|
||||
assert read_result.content[0].type == "text"
|
||||
result_text = read_result.content[0].text
|
||||
|
||||
# Should contain the note content and metadata
|
||||
assert "# Test Note" in result_text
|
||||
|
||||
@@ -53,11 +53,11 @@ async def test_search_basic_text_search(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(search_result) == 1
|
||||
assert search_result[0].type == "text"
|
||||
assert len(search_result.content) == 1
|
||||
assert search_result.content[0].type == "text"
|
||||
|
||||
# Parse the response (it should be a SearchResponse)
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Python Programming Guide" in result_text
|
||||
assert "Flask Web Development" in result_text
|
||||
assert "JavaScript Basics" not in result_text
|
||||
@@ -107,7 +107,7 @@ async def test_search_boolean_operators(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Python Flask Tutorial" in result_text
|
||||
assert "Python Django Guide" not in result_text
|
||||
assert "React JavaScript" not in result_text
|
||||
@@ -120,7 +120,7 @@ async def test_search_boolean_operators(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Python Flask Tutorial" in result_text
|
||||
assert "Python Django Guide" in result_text
|
||||
assert "React JavaScript" not in result_text
|
||||
@@ -133,7 +133,7 @@ async def test_search_boolean_operators(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Python Flask Tutorial" in result_text
|
||||
assert "Python Django Guide" not in result_text
|
||||
|
||||
@@ -173,7 +173,7 @@ async def test_search_title_only(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Database Design" in result_text
|
||||
assert "Web Development" not in result_text # Has "database" in content but not title
|
||||
|
||||
@@ -213,7 +213,7 @@ async def test_search_permalink_exact(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "API Documentation" in result_text
|
||||
assert "API Testing" not in result_text
|
||||
|
||||
@@ -263,7 +263,7 @@ async def test_search_permalink_pattern(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Meeting Notes January" in result_text
|
||||
assert "Meeting Notes February" in result_text
|
||||
assert "Project Notes" not in result_text
|
||||
@@ -308,7 +308,7 @@ Regular content about development practices."""
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
# Should find the main entity but filter out observations/relations
|
||||
assert "Development Process" in result_text
|
||||
|
||||
@@ -340,7 +340,7 @@ async def test_search_pagination(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
# Should contain 5 results and pagination info
|
||||
assert '"current_page": 1' in result_text
|
||||
assert '"page_size": 5' in result_text
|
||||
@@ -355,7 +355,7 @@ async def test_search_pagination(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert '"current_page": 2' in result_text
|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@ async def test_search_no_results(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert '"results": []' in result_text or '"results":[]' in result_text
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ async def test_search_complex_boolean_query(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Python Web Development" in result_text
|
||||
assert "JavaScript Web Development" in result_text
|
||||
assert "Python Data Science" not in result_text # Has Python but not web
|
||||
@@ -464,5 +464,5 @@ async def test_search_case_insensitive(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
result_text = search_result[0].text
|
||||
result_text = search_result.content[0].text
|
||||
assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}"
|
||||
|
||||
@@ -26,9 +26,9 @@ async def test_write_note_basic_creation(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: basic/Simple Note.md" in response_text
|
||||
@@ -51,9 +51,9 @@ async def test_write_note_no_tags(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: test/No Tags Note.md" in response_text
|
||||
@@ -77,7 +77,7 @@ async def test_write_note_update_existing(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert "# Created note" in result1[0].text
|
||||
assert "# Created note" in result1.content[0].text
|
||||
|
||||
# Update the same note
|
||||
result2 = await client.call_tool(
|
||||
@@ -90,9 +90,9 @@ async def test_write_note_update_existing(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result2) == 1
|
||||
assert result2[0].type == "text"
|
||||
response_text = result2[0].text
|
||||
assert len(result2.content) == 1
|
||||
assert result2.content[0].type == "text"
|
||||
response_text = result2.content[0].text
|
||||
|
||||
assert "# Updated note" in response_text
|
||||
assert "file_path: test/Update Test.md" in response_text
|
||||
@@ -116,9 +116,9 @@ async def test_write_note_tag_array(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: test/Array Tags Test.md" in response_text
|
||||
@@ -153,9 +153,9 @@ async def test_write_note_custom_permalink(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: notes/Custom Permalink Note.md" in response_text
|
||||
@@ -179,9 +179,9 @@ async def test_write_note_unicode_content(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: test/Unicode Test 🌟.md" in response_text
|
||||
@@ -225,9 +225,9 @@ async def test_write_note_complex_content_with_observations_relations(mcp_server
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: knowledge/Complex Knowledge Note.md" in response_text
|
||||
@@ -275,9 +275,9 @@ async def test_write_note_preserve_frontmatter(mcp_server, app):
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
response_text = result[0].text
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
response_text = result.content[0].text
|
||||
|
||||
assert "# Created note" in response_text
|
||||
assert "file_path: test/Frontmatter Note.md" in response_text
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for async_client configuration."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from basic_memory.mcp.async_client import create_client
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
def test_create_client_uses_asgi_when_no_api_url():
|
||||
"""Test that create_client uses ASGI transport when api_url is None."""
|
||||
mock_config = BasicMemoryConfig(api_url=None)
|
||||
|
||||
with patch("basic_memory.mcp.async_client.ConfigManager") as mock_config_manager:
|
||||
mock_config_manager.return_value.load_config.return_value = mock_config
|
||||
|
||||
client = create_client()
|
||||
|
||||
assert isinstance(client, AsyncClient)
|
||||
assert isinstance(client._transport, ASGITransport)
|
||||
assert str(client.base_url) == "http://test"
|
||||
|
||||
|
||||
def test_create_client_uses_http_when_api_url_set():
|
||||
"""Test that create_client uses HTTP transport when api_url is configured."""
|
||||
remote_url = "https://api.basicmemory.example.com"
|
||||
mock_config = BasicMemoryConfig(api_url=remote_url)
|
||||
|
||||
with patch("basic_memory.mcp.async_client.ConfigManager") as mock_config_manager:
|
||||
mock_config_manager.return_value.load_config.return_value = mock_config
|
||||
|
||||
client = create_client()
|
||||
|
||||
assert isinstance(client, AsyncClient)
|
||||
assert not isinstance(client._transport, ASGITransport)
|
||||
assert str(client.base_url) == remote_url
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Tests for the project router API endpoints."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -76,40 +73,6 @@ async def test_get_project_info_content(test_graph, client, project_config, proj
|
||||
assert "test" in stats["entity_types"] or "entity" in stats["entity_types"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_info_watch_status(test_graph, client, project_config, project_url):
|
||||
"""Test that project-info correctly handles watch status."""
|
||||
# Create a mock watch status file
|
||||
mock_watch_status = {
|
||||
"running": True,
|
||||
"start_time": "2025-03-05T18:00:42.752435",
|
||||
"pid": 7321,
|
||||
"error_count": 0,
|
||||
"last_error": None,
|
||||
"last_scan": "2025-03-05T19:59:02.444416",
|
||||
"synced_files": 6,
|
||||
"recent_events": [],
|
||||
}
|
||||
|
||||
# Mock the Path.exists and Path.read_text methods
|
||||
with (
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.read_text", return_value=json.dumps(mock_watch_status)),
|
||||
):
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/project/info")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that watch status is included
|
||||
assert data["system"]["watch_status"] is not None
|
||||
assert data["system"]["watch_status"]["running"] is True
|
||||
assert data["system"]["watch_status"]["pid"] == 7321
|
||||
assert data["system"]["watch_status"]["synced_files"] == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_endpoint(test_config, test_graph, client, project_config, project_url):
|
||||
"""Test the list projects endpoint returns correctly structured data."""
|
||||
@@ -196,3 +159,236 @@ async def test_set_default_project_endpoint(test_config, client, project_service
|
||||
|
||||
# Verify it's actually set as default
|
||||
assert project_service.default_project == test_project_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_path_endpoint(
|
||||
test_config, client, project_service, project_url, tmp_path
|
||||
):
|
||||
"""Test the update project endpoint for changing project path."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-project"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
try:
|
||||
# Verify initial state
|
||||
project = await project_service.get_project(test_project_name)
|
||||
assert project is not None
|
||||
assert project.path == old_path
|
||||
|
||||
# Update the project path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": new_path}
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert "old_project" in data
|
||||
assert "new_project" in data
|
||||
|
||||
# Check old project data
|
||||
assert data["old_project"]["name"] == test_project_name
|
||||
assert data["old_project"]["path"] == old_path
|
||||
|
||||
# Check new project data
|
||||
assert data["new_project"]["name"] == test_project_name
|
||||
assert data["new_project"]["path"] == new_path
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_is_active_endpoint(test_config, client, project_service, project_url):
|
||||
"""Test the update project endpoint for changing is_active status."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-active-project"
|
||||
test_path = "/tmp/test-update-active"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Update the project is_active status
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"is_active": False}
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert f"Project '{test_project_name}' updated successfully" == data["message"]
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_both_params_endpoint(
|
||||
test_config, client, project_service, project_url, tmp_path
|
||||
):
|
||||
"""Test the update project endpoint with both path and is_active parameters."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-both-project"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
try:
|
||||
# Update both path and is_active (path should take precedence)
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": new_path, "is_active": False},
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that path update was performed (takes precedence)
|
||||
assert data["new_project"]["path"] == new_path
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_nonexistent_endpoint(client, project_url):
|
||||
"""Test the update project endpoint with a nonexistent project."""
|
||||
# Try to update a project that doesn't exist
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/nonexistent-project", json={"path": "/tmp/new-path"}
|
||||
)
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "not found in configuration" in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_relative_path_error_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with relative path (should fail)."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-relative-project"
|
||||
test_path = "/tmp/test-update-relative"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Try to update with relative path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": "./relative-path"}
|
||||
)
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "Path must be absolute" in data["detail"]
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_no_params_endpoint(test_config, client, project_service, project_url):
|
||||
"""Test the update project endpoint with no parameters (should fail)."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-no-params-project"
|
||||
test_path = "/tmp/test-update-no-params"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
assert proj_info.path == test_path
|
||||
|
||||
try:
|
||||
# Try to update with no parameters
|
||||
response = await client.patch(f"{project_url}/project/{test_project_name}", json={})
|
||||
|
||||
# Should return 200 (no-op)
|
||||
assert response.status_code == 200
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
assert proj_info.path == test_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_empty_path_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with empty path parameter."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-empty-path-project"
|
||||
test_path = "/tmp/test-update-empty-path"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Try to update with empty/null path - should be treated as no path update
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": None, "is_active": True}
|
||||
)
|
||||
|
||||
# Should succeed and perform is_active update
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
"""Tests for CLI auth commands."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from typer.testing import CliRunner
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from basic_memory.cli.commands.auth import auth_app
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
|
||||
|
||||
class TestAuthCommands:
|
||||
"""Test CLI auth commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""Create a CLI test runner."""
|
||||
return CliRunner()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider(self):
|
||||
"""Create a mock OAuth provider."""
|
||||
provider = MagicMock()
|
||||
provider.register_client = AsyncMock()
|
||||
provider.get_client = AsyncMock()
|
||||
provider.authorize = AsyncMock()
|
||||
provider.load_authorization_code = AsyncMock()
|
||||
provider.exchange_authorization_code = AsyncMock()
|
||||
provider.load_access_token = AsyncMock()
|
||||
return provider
|
||||
|
||||
def test_register_client_default_values(self, runner, mock_provider):
|
||||
"""Test client registration with default values."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
# Mock the client info to capture what gets passed to register_client
|
||||
captured_client_info = None
|
||||
original_client_id = None
|
||||
original_client_secret = None
|
||||
|
||||
async def capture_register_client(client_info):
|
||||
nonlocal captured_client_info, original_client_id, original_client_secret
|
||||
captured_client_info = client_info
|
||||
# Capture original values before modification
|
||||
original_client_id = client_info.client_id
|
||||
original_client_secret = client_info.client_secret
|
||||
# Simulate auto-generation of IDs
|
||||
client_info.client_id = "auto-generated-id"
|
||||
client_info.client_secret = "auto-generated-secret"
|
||||
|
||||
mock_provider.register_client.side_effect = capture_register_client
|
||||
|
||||
result = runner.invoke(auth_app, ["register-client"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Client registered successfully!" in result.stdout
|
||||
assert "Client ID: auto-generated-id" in result.stdout
|
||||
assert "Client Secret: auto-generated-secret" in result.stdout
|
||||
assert "Save these credentials securely" in result.stdout
|
||||
|
||||
# Verify provider was created with default issuer URL
|
||||
mock_provider_class.assert_called_once_with(issuer_url="http://localhost:8000")
|
||||
|
||||
# Verify register_client was called
|
||||
mock_provider.register_client.assert_called_once()
|
||||
|
||||
# Verify the client info had correct defaults (using captured original values)
|
||||
assert captured_client_info is not None
|
||||
assert original_client_id == "" # Empty string for auto-generation
|
||||
assert original_client_secret == "" # Empty string for auto-generation
|
||||
assert captured_client_info.redirect_uris == [
|
||||
AnyHttpUrl("http://localhost:8000/callback")
|
||||
]
|
||||
assert captured_client_info.client_name == "Basic Memory OAuth Client"
|
||||
assert captured_client_info.grant_types == ["authorization_code", "refresh_token"]
|
||||
|
||||
def test_register_client_custom_values(self, runner, mock_provider):
|
||||
"""Test client registration with custom values."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
captured_client_info = None
|
||||
|
||||
async def capture_register_client(client_info):
|
||||
nonlocal captured_client_info
|
||||
captured_client_info = client_info
|
||||
# Don't modify the provided IDs
|
||||
|
||||
mock_provider.register_client.side_effect = capture_register_client
|
||||
|
||||
result = runner.invoke(
|
||||
auth_app,
|
||||
[
|
||||
"register-client",
|
||||
"--client-id",
|
||||
"custom-client-id",
|
||||
"--client-secret",
|
||||
"custom-client-secret",
|
||||
"--issuer-url",
|
||||
"https://custom.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Client registered successfully!" in result.stdout
|
||||
assert "Client ID: custom-client-id" in result.stdout
|
||||
assert "Client Secret: custom-client-secret" in result.stdout
|
||||
|
||||
# Verify provider was created with custom issuer URL
|
||||
mock_provider_class.assert_called_once_with(issuer_url="https://custom.example.com")
|
||||
|
||||
# Verify the client info had custom values
|
||||
assert captured_client_info is not None
|
||||
assert captured_client_info.client_id == "custom-client-id"
|
||||
assert captured_client_info.client_secret == "custom-client-secret"
|
||||
|
||||
def test_register_client_exception_handling(self, runner, mock_provider):
|
||||
"""Test client registration error handling."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
mock_provider.register_client.side_effect = Exception("Registration failed")
|
||||
|
||||
result = runner.invoke(auth_app, ["register-client"])
|
||||
|
||||
# Should fail with exception
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_test_auth_success_flow(self, runner, mock_provider):
|
||||
"""Test successful OAuth test flow."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
# Mock successful flow
|
||||
test_client = OAuthClientInformationFull(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
async def register_client_side_effect(client_info):
|
||||
# Simulate setting the client_id after registration
|
||||
client_info.client_id = "test-client-id"
|
||||
client_info.client_secret = "test-secret"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = test_client
|
||||
mock_provider.authorize.return_value = (
|
||||
"http://localhost:8000/callback?code=test-auth-code&state=test-state"
|
||||
)
|
||||
|
||||
# Mock authorization code object
|
||||
mock_auth_code = MagicMock()
|
||||
mock_provider.load_authorization_code.return_value = mock_auth_code
|
||||
|
||||
# Mock token response
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = "test-access-token"
|
||||
mock_token.refresh_token = "test-refresh-token"
|
||||
mock_token.expires_in = 3600
|
||||
mock_provider.exchange_authorization_code.return_value = mock_token
|
||||
|
||||
# Mock access token validation
|
||||
mock_access_token_obj = MagicMock()
|
||||
mock_access_token_obj.client_id = "test-client-id"
|
||||
mock_access_token_obj.scopes = ["read", "write"]
|
||||
mock_provider.load_access_token.return_value = mock_access_token_obj
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Registered test client:" in result.stdout
|
||||
assert "Authorization URL:" in result.stdout
|
||||
assert "Access token: test-access-token" in result.stdout
|
||||
assert "Refresh token: test-refresh-token" in result.stdout
|
||||
assert "Expires in: 3600 seconds" in result.stdout
|
||||
assert "Access token validated successfully!" in result.stdout
|
||||
assert "Client ID: test-client-id" in result.stdout
|
||||
assert "Scopes: ['read', 'write']" in result.stdout
|
||||
|
||||
# Verify all the expected calls were made
|
||||
mock_provider.register_client.assert_called_once()
|
||||
mock_provider.get_client.assert_called_once()
|
||||
mock_provider.authorize.assert_called_once()
|
||||
mock_provider.load_authorization_code.assert_called_once()
|
||||
mock_provider.exchange_authorization_code.assert_called_once()
|
||||
mock_provider.load_access_token.assert_called_once()
|
||||
|
||||
def test_test_auth_custom_issuer_url(self, runner, mock_provider):
|
||||
"""Test OAuth test flow with custom issuer URL."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
# Setup minimal mocks to avoid errors
|
||||
async def register_client_side_effect(client_info):
|
||||
client_info.client_id = "test-client-id"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = None # This will cause early exit
|
||||
|
||||
result = runner.invoke(
|
||||
auth_app, ["test-auth", "--issuer-url", "https://custom-issuer.com"]
|
||||
)
|
||||
|
||||
# Should create provider with custom URL
|
||||
mock_provider_class.assert_called_once_with(issuer_url="https://custom-issuer.com")
|
||||
|
||||
# Should exit early due to client not found
|
||||
assert "Error: Client not found after registration" in result.stderr
|
||||
|
||||
def test_test_auth_client_not_found(self, runner, mock_provider):
|
||||
"""Test OAuth test flow when client is not found after registration."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
async def register_client_side_effect(client_info):
|
||||
client_info.client_id = "test-client-id"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = None
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
assert result.exit_code == 0 # Command completes but with error message
|
||||
assert "Error: Client not found after registration" in result.stderr
|
||||
|
||||
def test_test_auth_no_auth_code_in_url(self, runner, mock_provider):
|
||||
"""Test OAuth test flow when no auth code in URL."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
test_client = OAuthClientInformationFull(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
async def register_client_side_effect(client_info):
|
||||
client_info.client_id = "test-client-id"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = test_client
|
||||
mock_provider.authorize.return_value = (
|
||||
"http://localhost:8000/callback?state=test-state" # No code parameter
|
||||
)
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Error: No authorization code in URL" in result.stderr
|
||||
|
||||
def test_test_auth_invalid_auth_code(self, runner, mock_provider):
|
||||
"""Test OAuth test flow when authorization code is invalid."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
test_client = OAuthClientInformationFull(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
async def register_client_side_effect(client_info):
|
||||
client_info.client_id = "test-client-id"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = test_client
|
||||
mock_provider.authorize.return_value = (
|
||||
"http://localhost:8000/callback?code=invalid-code&state=test-state"
|
||||
)
|
||||
mock_provider.load_authorization_code.return_value = None # Invalid code
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Error: Invalid authorization code" in result.stderr
|
||||
|
||||
def test_test_auth_invalid_access_token(self, runner, mock_provider):
|
||||
"""Test OAuth test flow when access token validation fails."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
|
||||
test_client = OAuthClientInformationFull(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
async def register_client_side_effect(client_info):
|
||||
client_info.client_id = "test-client-id"
|
||||
|
||||
mock_provider.register_client.side_effect = register_client_side_effect
|
||||
mock_provider.get_client.return_value = test_client
|
||||
mock_provider.authorize.return_value = (
|
||||
"http://localhost:8000/callback?code=test-auth-code&state=test-state"
|
||||
)
|
||||
|
||||
mock_auth_code = MagicMock()
|
||||
mock_provider.load_authorization_code.return_value = mock_auth_code
|
||||
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = "test-access-token"
|
||||
mock_token.refresh_token = "test-refresh-token"
|
||||
mock_token.expires_in = 3600
|
||||
mock_provider.exchange_authorization_code.return_value = mock_token
|
||||
|
||||
mock_provider.load_access_token.return_value = None # Invalid token
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Access token: test-access-token" in result.stdout
|
||||
assert "Error: Invalid access token" in result.stderr
|
||||
|
||||
def test_test_auth_exception_handling(self, runner, mock_provider):
|
||||
"""Test OAuth test flow exception handling."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.auth.BasicMemoryOAuthProvider"
|
||||
) as mock_provider_class:
|
||||
mock_provider_class.return_value = mock_provider
|
||||
mock_provider.register_client.side_effect = Exception("Test exception")
|
||||
|
||||
result = runner.invoke(auth_app, ["test-auth"])
|
||||
|
||||
# Should fail with exception
|
||||
assert result.exit_code != 0
|
||||
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app, import_app
|
||||
from basic_memory.cli.commands import import_chatgpt # noqa
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
@@ -176,6 +176,8 @@ def test_import_chatgpt_command_invalid_json(tmp_path):
|
||||
def test_import_chatgpt_with_custom_folder(tmp_path, sample_chatgpt_json, monkeypatch):
|
||||
"""Test import with custom conversations folder."""
|
||||
# Set up test environment
|
||||
|
||||
config = get_project_config()
|
||||
config.home = tmp_path
|
||||
conversations_folder = "chats"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands import import_claude_conversations # noqa
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
@@ -86,6 +86,7 @@ def test_import_conversations_command_invalid_json(tmp_path):
|
||||
def test_import_conversations_with_custom_folder(tmp_path, sample_conversations_json, monkeypatch):
|
||||
"""Test import with custom conversations folder."""
|
||||
# Set up test environment
|
||||
config = get_project_config()
|
||||
config.home = tmp_path
|
||||
conversations_folder = "chats"
|
||||
|
||||
@@ -134,6 +135,7 @@ def test_import_conversation_with_attachments(tmp_path):
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
json.dump([conversation], f)
|
||||
|
||||
config = get_project_config()
|
||||
# Set up environment
|
||||
config.home = tmp_path
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.import_claude_projects import import_projects # noqa
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
@@ -59,6 +59,7 @@ def test_import_projects_command_file_not_found(tmp_path):
|
||||
def test_import_projects_command_success(tmp_path, sample_projects_json, monkeypatch):
|
||||
"""Test successful project import via command."""
|
||||
# Set up test environment
|
||||
config = get_project_config()
|
||||
config.home = tmp_path
|
||||
|
||||
# Run import
|
||||
@@ -83,6 +84,7 @@ def test_import_projects_command_invalid_json(tmp_path):
|
||||
def test_import_projects_with_base_folder(tmp_path, sample_projects_json, monkeypatch):
|
||||
"""Test import with custom base folder."""
|
||||
# Set up test environment
|
||||
config = get_project_config()
|
||||
config.home = tmp_path
|
||||
base_folder = "claude-exports"
|
||||
|
||||
@@ -130,6 +132,7 @@ def test_import_project_without_prompt(tmp_path):
|
||||
json.dump([project], f)
|
||||
|
||||
# Set up environment
|
||||
config = get_project_config()
|
||||
config.home = tmp_path
|
||||
|
||||
# Run import
|
||||
|
||||
@@ -113,3 +113,43 @@ def test_import_json_command_handle_old_format(tmp_path):
|
||||
result = runner.invoke(import_app, ["memory-json", str(json_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
|
||||
|
||||
def test_import_json_command_missing_name_key(tmp_path):
|
||||
"""Test handling JSON with missing 'name' key using 'id' instead."""
|
||||
# Create JSON with id instead of name (common in Knowledge Graph Memory Server)
|
||||
data_with_id = [
|
||||
{
|
||||
"type": "entity",
|
||||
"id": "test_entity_id",
|
||||
"entityType": "test",
|
||||
"observations": ["Test observation with id"],
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
"entityName": "test_entity_2",
|
||||
"entityType": "test",
|
||||
"observations": ["Test observation with entityName"],
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
"name": "test_entity_title",
|
||||
"entityType": "test",
|
||||
"observations": ["Test observation with name"],
|
||||
},
|
||||
]
|
||||
|
||||
json_file = tmp_path / "missing_name.json"
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
for item in data_with_id:
|
||||
f.write(json.dumps(item) + "\n")
|
||||
|
||||
# Set up test environment
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import - should not fail even without 'name' key
|
||||
result = runner.invoke(import_app, ["memory-json", str(json_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Created 3 entities" in result.output
|
||||
|
||||
@@ -141,3 +141,41 @@ def test_project_failure_exits_with_error(mock_run, cli_env):
|
||||
|
||||
assert default_result.exit_code == 1
|
||||
assert "Error setting default project" in default_result.output
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.project.asyncio.run")
|
||||
def test_project_move_command(mock_run, cli_env):
|
||||
"""Test the 'project move' command with mocked API."""
|
||||
# Mock the API response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"message": "Project 'test-project' updated successfully",
|
||||
"status": "success",
|
||||
"default": False,
|
||||
}
|
||||
mock_run.return_value = mock_response
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_app, ["project", "move", "test-project", "/new/path/to/project"])
|
||||
|
||||
# Verify it runs without exception
|
||||
assert result.exit_code == 0
|
||||
# Verify the important warning message is displayed
|
||||
assert "Manual File Movement Required" in result.output
|
||||
assert "You must manually move your project files" in result.output
|
||||
assert "/new/path/to/project" in result.output
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.project.asyncio.run")
|
||||
def test_project_move_command_failure(mock_run, cli_env):
|
||||
"""Test the 'project move' command with API failure."""
|
||||
# Mock an exception being raised
|
||||
mock_run.side_effect = Exception("Project not found")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_app, ["project", "move", "nonexistent-project", "/new/path"])
|
||||
|
||||
# Should exit with code 1 and show error message
|
||||
assert result.exit_code == 1
|
||||
assert "Error moving project" in result.output
|
||||
|
||||
+11
-20
@@ -11,7 +11,7 @@ from basic_memory.cli.commands.sync import (
|
||||
group_issues_by_directory,
|
||||
ValidationIssue,
|
||||
)
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
|
||||
# Set up CLI runner
|
||||
@@ -74,6 +74,7 @@ def test_display_detailed_sync_results_with_changes():
|
||||
async def test_run_sync_basic(sync_service, project_config, test_project):
|
||||
"""Test basic sync operation."""
|
||||
# Set up test environment
|
||||
config = get_project_config()
|
||||
config.home = project_config.home
|
||||
config.name = test_project.name
|
||||
|
||||
@@ -99,19 +100,14 @@ def test_sync_command():
|
||||
mock_run_sync.return_value = None
|
||||
|
||||
# Mock config values that the sync command prints
|
||||
with patch("basic_memory.cli.commands.sync.config") as mock_config:
|
||||
mock_config.project = "test-project"
|
||||
mock_config.home = "/test/path"
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
# Verify output contains project info
|
||||
assert "Syncing project: test-project" in result.stdout
|
||||
|
||||
# Verify output contains project info
|
||||
assert "Syncing project: test-project" in result.stdout
|
||||
assert "Project path: /test/path" in result.stdout
|
||||
|
||||
# Verify the function was called with verbose=True
|
||||
mock_run_sync.assert_called_once_with(verbose=True)
|
||||
# Verify the function was called with verbose=True
|
||||
mock_run_sync.assert_called_once_with(verbose=True)
|
||||
|
||||
|
||||
def test_sync_command_error():
|
||||
@@ -123,11 +119,6 @@ def test_sync_command_error():
|
||||
# Mock an error
|
||||
mock_run_sync.side_effect = Exception("Sync failed")
|
||||
|
||||
# Mock config values that the sync command prints
|
||||
with patch("basic_memory.cli.commands.sync.config") as mock_config:
|
||||
mock_config.project = "test-project"
|
||||
mock_config.home = "/test/path"
|
||||
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during sync: Sync failed" in result.stderr
|
||||
result = runner.invoke(app, ["sync", "--verbose"])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during sync: Sync failed" in result.stderr
|
||||
|
||||
+3
-42
@@ -51,6 +51,8 @@ def project_root() -> Path:
|
||||
def config_home(tmp_path, monkeypatch) -> Path:
|
||||
# Patch HOME environment variable for the duration of the test
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# Set BASIC_MEMORY_HOME to the test directory
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@@ -66,8 +68,6 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
update_permalinks_on_move=True,
|
||||
)
|
||||
|
||||
# Patch the module app_config instance for the duration of the test
|
||||
monkeypatch.setattr("basic_memory.config.app_config", app_config)
|
||||
return app_config
|
||||
|
||||
|
||||
@@ -82,37 +82,8 @@ def config_manager(
|
||||
config_manager.config_file = config_manager.config_dir / "config.json"
|
||||
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override the config directly instead of relying on disk load
|
||||
config_manager.config = app_config
|
||||
|
||||
# Ensure the config file is written to disk
|
||||
config_manager.save_config(app_config)
|
||||
|
||||
# Patch the config_manager in all locations where it's imported
|
||||
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
|
||||
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
|
||||
|
||||
# Mock get_project_config to return test project config for test-project, fallback for others
|
||||
def mock_get_project_config(project_name=None):
|
||||
if project_name == "test-project" or project_name is None:
|
||||
return project_config
|
||||
# For any other project name, return a default config pointing to test location
|
||||
fallback_config = ProjectConfig(name=project_name or "main", home=Path(config_home))
|
||||
return fallback_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.mcp.project_session.get_project_config", mock_get_project_config
|
||||
)
|
||||
|
||||
# Patch the project config that CLI commands import (only modules that actually import config)
|
||||
monkeypatch.setattr("basic_memory.cli.commands.sync.config", project_config)
|
||||
monkeypatch.setattr("basic_memory.cli.commands.status.config", project_config)
|
||||
monkeypatch.setattr("basic_memory.cli.commands.import_memory_json.config", project_config)
|
||||
monkeypatch.setattr("basic_memory.cli.commands.import_claude_projects.config", project_config)
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.cli.commands.import_claude_conversations.config", project_config
|
||||
)
|
||||
monkeypatch.setattr("basic_memory.cli.commands.import_chatgpt.config", project_config)
|
||||
return config_manager
|
||||
|
||||
|
||||
@@ -126,7 +97,7 @@ def project_session(test_project: Project):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def project_config(test_project, monkeypatch):
|
||||
def project_config(test_project):
|
||||
"""Create test project configuration."""
|
||||
|
||||
project_config = ProjectConfig(
|
||||
@@ -134,8 +105,6 @@ def project_config(test_project, monkeypatch):
|
||||
home=Path(test_project.path),
|
||||
)
|
||||
|
||||
# Patch the config module project config for the duration of the test
|
||||
monkeypatch.setattr("basic_memory.config.config", project_config)
|
||||
return project_config
|
||||
|
||||
|
||||
@@ -150,14 +119,6 @@ class TestConfig:
|
||||
@pytest.fixture
|
||||
def test_config(config_home, project_config, app_config, config_manager) -> TestConfig:
|
||||
"""All test configuration fixtures"""
|
||||
|
||||
@dataclass
|
||||
class TestConfig:
|
||||
config_home: Path
|
||||
project_config: ProjectConfig
|
||||
app_config: BasicMemoryConfig
|
||||
config_manager: ConfigManager
|
||||
|
||||
return TestConfig(config_home, project_config, app_config, config_manager)
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ from basic_memory.deps import get_project_config, get_engine_factory, get_app_co
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.mcp.server import mcp as mcp_server
|
||||
|
||||
from basic_memory.config import app_config as basic_memory_app_config # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mcp() -> FastMCP:
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
"""Tests for OAuth authentication provider."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from basic_memory.mcp.auth_provider import (
|
||||
BasicMemoryOAuthProvider,
|
||||
BasicMemoryAccessToken,
|
||||
BasicMemoryRefreshToken,
|
||||
)
|
||||
|
||||
|
||||
class TestBasicMemoryOAuthProvider:
|
||||
"""Test the BasicMemoryOAuthProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create a test OAuth provider."""
|
||||
return BasicMemoryOAuthProvider(issuer_url="http://localhost:8000")
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a test client."""
|
||||
return OAuthClientInformationFull(
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_client(self, provider):
|
||||
"""Test client registration."""
|
||||
# Register without ID/secret (auto-generated)
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id="", # Will be auto-generated
|
||||
client_secret="", # Will be auto-generated
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")],
|
||||
)
|
||||
await provider.register_client(client_info)
|
||||
|
||||
assert client_info.client_id is not None
|
||||
assert client_info.client_secret is not None
|
||||
|
||||
# Verify client is stored
|
||||
stored_client = await provider.get_client(client_info.client_id)
|
||||
assert stored_client is not None
|
||||
assert stored_client.client_id == client_info.client_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_flow(self, provider, client):
|
||||
"""Test the complete authorization flow."""
|
||||
# Register the client first
|
||||
await provider.register_client(client)
|
||||
|
||||
# Create authorization request
|
||||
auth_params = AuthorizationParams(
|
||||
state="test-state",
|
||||
scopes=["read", "write"],
|
||||
code_challenge="test-challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:3000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
# Get authorization URL
|
||||
auth_url = await provider.authorize(client, auth_params)
|
||||
|
||||
# Verify URL format
|
||||
assert "code=" in auth_url
|
||||
assert "state=test-state" in auth_url
|
||||
assert auth_url.startswith("http://localhost:3000/callback")
|
||||
|
||||
# Extract auth code
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
parsed = urlparse(auth_url)
|
||||
params = parse_qs(parsed.query)
|
||||
auth_code = params.get("code", [None])[0]
|
||||
|
||||
assert auth_code is not None
|
||||
|
||||
# Load authorization code
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
assert code_obj is not None
|
||||
assert code_obj.client_id == client.client_id
|
||||
assert code_obj.scopes == ["read", "write"]
|
||||
|
||||
# Exchange for tokens
|
||||
token = await provider.exchange_authorization_code(client, code_obj)
|
||||
|
||||
assert token.access_token is not None
|
||||
assert token.refresh_token is not None
|
||||
assert token.expires_in == 3600
|
||||
assert token.scope == "read write"
|
||||
|
||||
# Verify authorization code is removed
|
||||
code_obj2 = await provider.load_authorization_code(client, auth_code)
|
||||
assert code_obj2 is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_token_validation(self, provider, client):
|
||||
"""Test access token validation."""
|
||||
# Register the client first
|
||||
await provider.register_client(client)
|
||||
|
||||
# Get a valid token through the flow
|
||||
auth_params = AuthorizationParams(
|
||||
state="test",
|
||||
scopes=["read"],
|
||||
code_challenge="challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:3000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
auth_url = await provider.authorize(client, auth_params)
|
||||
auth_code = auth_url.split("code=")[1].split("&")[0]
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
token = await provider.exchange_authorization_code(client, code_obj)
|
||||
|
||||
# Validate access token
|
||||
access_token_obj = await provider.load_access_token(token.access_token)
|
||||
assert access_token_obj is not None
|
||||
assert access_token_obj.client_id == client.client_id
|
||||
assert access_token_obj.scopes == ["read"]
|
||||
|
||||
# Test invalid token
|
||||
invalid_token = await provider.load_access_token("invalid-token")
|
||||
assert invalid_token is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token_flow(self, provider, client):
|
||||
"""Test refresh token exchange."""
|
||||
# Register the client first
|
||||
await provider.register_client(client)
|
||||
|
||||
# Get initial tokens
|
||||
auth_params = AuthorizationParams(
|
||||
state="test",
|
||||
scopes=["read", "write"],
|
||||
code_challenge="challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:3000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
auth_url = await provider.authorize(client, auth_params)
|
||||
auth_code = auth_url.split("code=")[1].split("&")[0]
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
initial_token = await provider.exchange_authorization_code(client, code_obj)
|
||||
|
||||
# Load refresh token
|
||||
refresh_token_obj = await provider.load_refresh_token(client, initial_token.refresh_token)
|
||||
assert refresh_token_obj is not None
|
||||
|
||||
# Exchange for new tokens
|
||||
new_token = await provider.exchange_refresh_token(
|
||||
client,
|
||||
refresh_token_obj,
|
||||
["read"], # Request fewer scopes
|
||||
)
|
||||
|
||||
assert new_token.access_token != initial_token.access_token
|
||||
assert new_token.refresh_token != initial_token.refresh_token
|
||||
assert new_token.scope == "read"
|
||||
|
||||
# Old refresh token should be invalid
|
||||
old_refresh = await provider.load_refresh_token(client, initial_token.refresh_token)
|
||||
assert old_refresh is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_revocation(self, provider, client):
|
||||
"""Test token revocation.
|
||||
|
||||
Note: JWT tokens are self-contained and cannot be truly revoked.
|
||||
This test verifies that tokens are removed from the in-memory cache,
|
||||
but they will still be valid if decoded directly.
|
||||
"""
|
||||
# Register the client first
|
||||
await provider.register_client(client)
|
||||
|
||||
# Create a token directly in memory (not JWT) to test revocation
|
||||
token_str = "test-access-token"
|
||||
access_token = BasicMemoryAccessToken(
|
||||
token=token_str,
|
||||
client_id=client.client_id,
|
||||
scopes=["read", "write"],
|
||||
expires_at=int((datetime.utcnow() + timedelta(hours=1)).timestamp()),
|
||||
)
|
||||
provider.access_tokens[token_str] = access_token
|
||||
|
||||
# Verify token is valid
|
||||
loaded_token = await provider.load_access_token(token_str)
|
||||
assert loaded_token is not None
|
||||
assert loaded_token.client_id == client.client_id
|
||||
|
||||
# Revoke token
|
||||
await provider.revoke_token(access_token)
|
||||
|
||||
# Verify token is removed from cache
|
||||
assert token_str not in provider.access_tokens
|
||||
|
||||
# For refresh tokens, test revocation works
|
||||
refresh_token_str = "test-refresh-token"
|
||||
refresh_token = BasicMemoryRefreshToken(
|
||||
token=refresh_token_str,
|
||||
client_id=client.client_id,
|
||||
scopes=["read", "write"],
|
||||
)
|
||||
provider.refresh_tokens[refresh_token_str] = refresh_token
|
||||
|
||||
# Revoke refresh token
|
||||
await provider.revoke_token(refresh_token)
|
||||
assert refresh_token_str not in provider.refresh_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_authorization_code(self, provider, client):
|
||||
"""Test expired authorization code handling."""
|
||||
# Register the client first
|
||||
await provider.register_client(client)
|
||||
|
||||
# Create auth code with past expiration
|
||||
auth_code = "expired-code"
|
||||
from basic_memory.mcp.auth_provider import BasicMemoryAuthorizationCode
|
||||
|
||||
provider.authorization_codes[auth_code] = BasicMemoryAuthorizationCode(
|
||||
code=auth_code,
|
||||
scopes=["read"],
|
||||
expires_at=(datetime.utcnow() - timedelta(minutes=1)).timestamp(),
|
||||
client_id=client.client_id,
|
||||
code_challenge="challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:3000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
# Try to load expired code
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
assert code_obj is None
|
||||
|
||||
# Verify code was cleaned up
|
||||
assert auth_code not in provider.authorization_codes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_access_token(self, provider, client):
|
||||
"""Test JWT access token generation and validation."""
|
||||
# Generate access token directly
|
||||
token = provider._generate_access_token(client.client_id, ["read", "write"])
|
||||
|
||||
# Decode and validate
|
||||
import jwt
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
provider.secret_key,
|
||||
algorithms=["HS256"],
|
||||
audience="basic-memory",
|
||||
issuer=provider.issuer_url,
|
||||
)
|
||||
|
||||
assert payload["sub"] == client.client_id
|
||||
assert payload["scopes"] == ["read", "write"]
|
||||
assert payload["aud"] == "basic-memory"
|
||||
assert payload["iss"] == provider.issuer_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_client(self, provider):
|
||||
"""Test operations with invalid client."""
|
||||
# Try to get non-existent client
|
||||
client = await provider.get_client("invalid-client")
|
||||
assert client is None
|
||||
|
||||
# Try to load auth code for invalid client
|
||||
fake_client = OAuthClientInformationFull(
|
||||
client_id="fake-client",
|
||||
client_secret="fake-secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")],
|
||||
)
|
||||
|
||||
code = await provider.load_authorization_code(fake_client, "some-code")
|
||||
assert code is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_access_token_in_memory(self, provider):
|
||||
"""Test that expired access tokens in memory are removed."""
|
||||
# Create an expired token directly in memory
|
||||
expired_token_str = "expired-access-token"
|
||||
expired_access_token = BasicMemoryAccessToken(
|
||||
token=expired_token_str,
|
||||
client_id="test-client",
|
||||
scopes=["read"],
|
||||
expires_at=int((datetime.utcnow() - timedelta(minutes=1)).timestamp()), # Expired
|
||||
)
|
||||
provider.access_tokens[expired_token_str] = expired_access_token
|
||||
|
||||
# Try to load the expired token - should return None and remove from memory
|
||||
result = await provider.load_access_token(expired_token_str)
|
||||
assert result is None
|
||||
assert expired_token_str not in provider.access_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_decode_success_path(self, provider):
|
||||
"""Test successful JWT decode path when token not in memory."""
|
||||
# Generate a valid JWT token
|
||||
jwt_token = provider._generate_access_token("test-client", ["read", "write"])
|
||||
|
||||
# Make sure it's not in memory cache
|
||||
assert jwt_token not in provider.access_tokens
|
||||
|
||||
# Load the token - should decode successfully
|
||||
result = await provider.load_access_token(jwt_token)
|
||||
assert result is not None
|
||||
assert result.client_id == "test-client"
|
||||
assert result.scopes == ["read", "write"]
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Tests for MCP server configuration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from basic_memory.mcp.server import create_auth_config
|
||||
|
||||
|
||||
class TestMCPServer:
|
||||
"""Test MCP server configuration."""
|
||||
|
||||
def test_create_auth_config_no_provider(self):
|
||||
"""Test auth config creation when no provider is specified."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
assert auth_settings is None
|
||||
assert auth_provider is None
|
||||
|
||||
def test_create_auth_config_github_provider(self):
|
||||
"""Test auth config creation with GitHub provider."""
|
||||
env_vars = {"FASTMCP_AUTH_ENABLED": "true", "FASTMCP_AUTH_PROVIDER": "github"}
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with patch("basic_memory.mcp.server.create_github_provider") as mock_create_github:
|
||||
mock_github_provider = MagicMock()
|
||||
mock_create_github.return_value = mock_github_provider
|
||||
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
assert auth_settings is not None
|
||||
assert auth_provider == mock_github_provider
|
||||
mock_create_github.assert_called_once()
|
||||
|
||||
def test_create_auth_config_google_provider(self):
|
||||
"""Test auth config creation with Google provider."""
|
||||
env_vars = {"FASTMCP_AUTH_ENABLED": "true", "FASTMCP_AUTH_PROVIDER": "google"}
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with patch("basic_memory.mcp.server.create_google_provider") as mock_create_google:
|
||||
mock_google_provider = MagicMock()
|
||||
mock_create_google.return_value = mock_google_provider
|
||||
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
assert auth_settings is not None
|
||||
assert auth_provider == mock_google_provider
|
||||
mock_create_google.assert_called_once()
|
||||
|
||||
def test_create_auth_config_supabase_provider_success(self):
|
||||
"""Test auth config creation with Supabase provider (success case)."""
|
||||
env_vars = {
|
||||
"FASTMCP_AUTH_ENABLED": "true",
|
||||
"FASTMCP_AUTH_PROVIDER": "supabase",
|
||||
"SUPABASE_URL": "https://test.supabase.co",
|
||||
"SUPABASE_ANON_KEY": "anon-key-123",
|
||||
"SUPABASE_SERVICE_KEY": "service-key-456",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with patch("basic_memory.mcp.server.SupabaseOAuthProvider") as mock_supabase_class:
|
||||
mock_supabase_provider = MagicMock()
|
||||
mock_supabase_class.return_value = mock_supabase_provider
|
||||
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
assert auth_settings is not None
|
||||
assert auth_provider == mock_supabase_provider
|
||||
mock_supabase_class.assert_called_once_with(
|
||||
supabase_url="https://test.supabase.co",
|
||||
supabase_anon_key="anon-key-123",
|
||||
supabase_service_key="service-key-456",
|
||||
issuer_url="http://localhost:8000", # Default issuer URL is added
|
||||
)
|
||||
|
||||
def test_create_auth_config_supabase_provider_missing_url(self):
|
||||
"""Test auth config creation with Supabase provider missing URL."""
|
||||
env_vars = {
|
||||
"FASTMCP_AUTH_ENABLED": "true",
|
||||
"FASTMCP_AUTH_PROVIDER": "supabase",
|
||||
"SUPABASE_ANON_KEY": "anon-key-123",
|
||||
# Missing SUPABASE_URL
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with pytest.raises(ValueError, match="SUPABASE_URL and SUPABASE_ANON_KEY must be set"):
|
||||
create_auth_config()
|
||||
|
||||
def test_create_auth_config_supabase_provider_missing_anon_key(self):
|
||||
"""Test auth config creation with Supabase provider missing anon key."""
|
||||
env_vars = {
|
||||
"FASTMCP_AUTH_ENABLED": "true",
|
||||
"FASTMCP_AUTH_PROVIDER": "supabase",
|
||||
"SUPABASE_URL": "https://test.supabase.co",
|
||||
# Missing SUPABASE_ANON_KEY
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with pytest.raises(ValueError, match="SUPABASE_URL and SUPABASE_ANON_KEY must be set"):
|
||||
create_auth_config()
|
||||
|
||||
def test_create_auth_config_basic_memory_provider(self):
|
||||
"""Test auth config creation with basic-memory provider."""
|
||||
env_vars = {
|
||||
"FASTMCP_AUTH_ENABLED": "true",
|
||||
"FASTMCP_AUTH_PROVIDER": "basic-memory",
|
||||
"FASTMCP_AUTH_SECRET_KEY": "test-secret-key",
|
||||
"FASTMCP_AUTH_ISSUER_URL": "https://custom-issuer.com",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with patch(
|
||||
"basic_memory.mcp.server.BasicMemoryOAuthProvider"
|
||||
) as mock_basic_memory_class:
|
||||
mock_basic_memory_provider = MagicMock()
|
||||
mock_basic_memory_class.return_value = mock_basic_memory_provider
|
||||
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
assert auth_settings is not None
|
||||
assert auth_provider == mock_basic_memory_provider
|
||||
mock_basic_memory_class.assert_called_once_with(
|
||||
issuer_url="https://custom-issuer.com"
|
||||
)
|
||||
|
||||
def test_create_auth_config_basic_memory_provider_default_issuer(self):
|
||||
"""Test auth config creation with basic-memory provider using default issuer."""
|
||||
env_vars = {
|
||||
"FASTMCP_AUTH_ENABLED": "true",
|
||||
"FASTMCP_AUTH_PROVIDER": "basic-memory",
|
||||
"FASTMCP_AUTH_SECRET_KEY": "test-secret-key",
|
||||
# No FASTMCP_AUTH_ISSUER_URL - should use default
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars):
|
||||
with patch(
|
||||
"basic_memory.mcp.server.BasicMemoryOAuthProvider"
|
||||
) as mock_basic_memory_class:
|
||||
mock_basic_memory_provider = MagicMock()
|
||||
mock_basic_memory_class.return_value = mock_basic_memory_provider
|
||||
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
assert auth_settings is not None
|
||||
assert auth_provider == mock_basic_memory_provider
|
||||
mock_basic_memory_class.assert_called_once_with(issuer_url="http://localhost:8000")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user