Compare commits

..

9 Commits

Author SHA1 Message Date
Drew Cain 7adf6791e9 fix: #190 MCP Hub tool validation
Signed-off-by: Drew Cain <groksrc@gmail.com>
2025-06-30 23:12:28 -05:00
github-actions[bot] 1dc66bec7a 📊 Daily metrics update - 2025-07-01 2025-07-01 01:24:38 +00:00
nellins 42d97504a3 Update daily_report.py
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:24:08 -05:00
nellins 6112e185fd Create daily_report.py
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:20:42 -05:00
nellins 2e758414da Create requirements.txt
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:19:22 -05:00
nellins 3ec9ca234e Create daily-traction.yml
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:10:49 -05:00
nellins 9dec7e98a8 Create daily_report.py
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:05:32 -05:00
nellins 338d225a7e Create requirements.txt
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:04:04 -05:00
nellins 211b760522 Create daily-traction.yml
Signed-off-by: nellins <drewnellins@gmail.com>
2025-06-30 20:03:01 -05:00
102 changed files with 4533 additions and 4400 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Daily Traction Report
on:
schedule:
- cron: '0 14 * * 1-5'
workflow_dispatch:
jobs:
generate-report:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r scripts/requirements.txt
- name: Generate Daily Traction Report
run: python scripts/daily_report.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
REDDIT_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
REDDIT_SECRET: ${{ secrets.REDDIT_SECRET }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
-46
View File
@@ -1,51 +1,5 @@
# CHANGELOG
## v0.14.2 (2025-07-03)
### Bug Fixes
- **#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
- **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
## 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
+1 -6
View File
@@ -27,12 +27,7 @@ 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. **Activate the Virtual Environment**
```bash
source .venv/bin/activate
```
4. **Run the Tests**:
3. **Run the Tests**:
```bash
# Run all tests
just test
+1 -8
View File
@@ -70,13 +70,6 @@ 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:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=basic-memory&config=eyJjb21tYW5kIjoiL1VzZXJzL2RyZXcvLmxvY2FsL2Jpbi91dnggYmFzaWMtbWVtb3J5IG1jcCJ9)
### Glama.ai
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
@@ -225,7 +218,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
+44
View File
@@ -0,0 +1,44 @@
{
"date": "2025-07-01T01:24:37.731009",
"metrics": {
"github": {
"stars": 1009,
"forks": 73,
"watchers": 1009,
"open_issues": 31,
"traffic_views": 0,
"traffic_unique": 0,
"recent_issues": 0
},
"reddit": {
"total_mentions": 25,
"subreddit_members": 43,
"top_posts": [
{
"title": "Today's banning is the largest since Affinity, and...",
"score": 892,
"subreddit": "magicTCG",
"num_comments": 127
},
{
"title": "Basic Memory - Backed by Research*",
"score": 1,
"subreddit": "basicmemory",
"num_comments": 0
},
{
"title": "n8n Full Course \u2699\ufe0f Create Our Second Flow \ud83d\udc49 Trigge...",
"score": 1,
"subreddit": "Tech_UpSkill",
"num_comments": 0
}
],
"hot_discussions": []
},
"youtube": {
"subscribers": 45,
"total_views": 1093,
"video_count": 6
}
}
}
+4 -4
View File
@@ -129,10 +129,10 @@ When using Docker volumes, you'll need to configure projects to point to your mo
2. **Add a project for your mounted volume:**
```bash
# If you mounted /path/to/your/vault to /app/data
docker exec basic-memory-server basic-memory project create my-vault /app/data
docker exec basic-memory-server basic-memory project add my-vault /app/data
# Set it as default
docker exec basic-memory-server basic-memory project set-default my-vault
docker exec basic-memory-server basic-memory project default my-vault
```
3. **Sync the new project:**
@@ -151,10 +151,10 @@ volumes:
Then configure it:
```bash
# Create project pointing to mounted vault
docker exec basic-memory-server basic-memory project create obsidian /app/data
docker exec basic-memory-server basic-memory project add obsidian /app/data
# Set as default
docker exec basic-memory-server basic-memory project set-default obsidian
docker exec basic-memory-server basic-memory project default obsidian
# Sync to index all files
docker exec basic-memory-server basic-memory sync
-241
View File
@@ -1,241 +0,0 @@
# 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.
+31
View File
@@ -0,0 +1,31 @@
name: Daily Traction Report
on:
schedule:
- cron: '0 14 * * 1-5' # 9 AM EST weekdays
workflow_dispatch: # Manual trigger for testing
jobs:
generate-report:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r scripts/requirements.txt
- name: Generate Daily Traction Report
run: python scripts/daily_report.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
REDDIT_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
REDDIT_SECRET: ${{ secrets.REDDIT_SECRET }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""
Basic Memory Daily Traction Report - Enhanced with Growth Tracking
Automated tracking across GitHub, Reddit, YouTube with daily change indicators
"""
import os
import requests
import json
from datetime import datetime, timedelta
import praw
from googleapiclient.discovery import build
from dateutil import parser
import base64
class BasicMemoryTracker:
def __init__(self):
self.github_token = os.getenv('GITHUB_TOKEN')
self.discord_webhook = os.getenv('DISCORD_WEBHOOK')
self.youtube_api_key = os.getenv('YOUTUBE_API_KEY')
# Reddit setup
self.reddit = praw.Reddit(
client_id=os.getenv('REDDIT_CLIENT_ID'),
client_secret=os.getenv('REDDIT_SECRET'),
user_agent='BasicMemoryTracker:v1.0'
)
# YouTube setup
self.youtube = build('youtube', 'v3', developerKey=self.youtube_api_key)
self.repo_owner = 'basicmachines-co'
self.repo_name = 'basic-memory'
self.youtube_channel = 'basicmachines-co'
self.metrics_file = 'data/daily_metrics.json'
def get_previous_metrics(self):
"""Get yesterday's metrics from GitHub repo storage"""
try:
headers = {'Authorization': f'token {self.github_token}'}
url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}'
response = requests.get(url, headers=headers)
if response.status_code == 200:
file_data = response.json()
content = base64.b64decode(file_data['content']).decode('utf-8')
return json.loads(content)
else:
print("📝 No previous metrics found - this is the first run!")
return {}
except Exception as e:
print(f"⚠️ Could not load previous metrics: {e}")
return {}
def save_current_metrics(self, metrics):
"""Save today's metrics to GitHub repo for tomorrow's comparison"""
try:
headers = {'Authorization': f'token {self.github_token}'}
# Prepare data
metrics_data = {
'date': datetime.now().isoformat(),
'metrics': metrics
}
content = json.dumps(metrics_data, indent=2)
encoded_content = base64.b64encode(content.encode()).decode()
# Check if file exists
file_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}'
existing_response = requests.get(file_url, headers=headers)
payload = {
'message': f'📊 Daily metrics update - {datetime.now().strftime("%Y-%m-%d")}',
'content': encoded_content
}
if existing_response.status_code == 200:
# File exists, update it
payload['sha'] = existing_response.json()['sha']
response = requests.put(file_url, headers=headers, json=payload)
else:
# File doesn't exist, create it
response = requests.put(file_url, headers=headers, json=payload)
if response.status_code in [200, 201]:
print("✅ Metrics saved for tomorrow's comparison!")
else:
print(f"⚠️ Failed to save metrics: {response.status_code}")
except Exception as e:
print(f"⚠️ Error saving metrics: {e}")
def calculate_change(self, current, previous, key):
"""Calculate the change between current and previous values"""
if not previous or key not in previous:
return 0, "🆕"
change = current - previous[key]
if change > 0:
return change, "📈"
elif change < 0:
return abs(change), "📉"
else:
return 0, "➡️"
def format_change(self, change, direction):
"""Format the change indicator for display"""
if direction == "🆕":
return "🆕"
elif direction == "📈":
return f"(+{change})"
elif direction == "📉":
return f"(-{change})"
else:
return "(±0)"
def get_github_metrics(self):
"""Get GitHub repository metrics"""
try:
headers = {'Authorization': f'token {self.github_token}'}
# Repository stats
repo_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}'
repo_response = requests.get(repo_url, headers=headers)
repo_data = repo_response.json()
# Traffic stats (requires push access)
traffic_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/traffic/views'
traffic_response = requests.get(traffic_url, headers=headers)
traffic_data = traffic_response.json() if traffic_response.status_code == 200 else {}
# Recent issues
issues_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/issues'
issues_response = requests.get(issues_url, headers=headers)
issues_data = issues_response.json() if issues_response.status_code == 200 else []
return {
'stars': repo_data.get('stargazers_count', 0),
'forks': repo_data.get('forks_count', 0),
'watchers': repo_data.get('watchers_count', 0),
'open_issues': repo_data.get('open_issues_count', 0),
'traffic_views': traffic_data.get('count', 0),
'traffic_unique': traffic_data.get('uniques', 0),
'recent_issues': len([i for i in issues_data if
parser.parse(i['created_at']).date() >= (datetime.now() - timedelta(days=1)).date()])
}
except Exception as e:
print(f"GitHub API error: {e}")
return {'error': str(e)}
def get_reddit_metrics(self):
"""Get Reddit metrics for Basic Memory mentions"""
try:
metrics = {
'total_mentions': 0,
'subreddit_members': 0,
'top_posts': [],
'hot_discussions': []
}
# Search for Basic Memory mentions
search_results = list(self.reddit.subreddit('all').search(
'Basic Memory', time_filter='day', limit=25
))
metrics['total_mentions'] = len(search_results)
# Get top posts
for post in search_results[:3]:
metrics['top_posts'].append({
'title': post.title[:50] + '...' if len(post.title) > 50 else post.title,
'score': post.score,
'subreddit': post.subreddit.display_name,
'num_comments': post.num_comments
})
# Check r/BasicMemory if it exists
try:
basic_memory_sub = self.reddit.subreddit('BasicMemory')
metrics['subreddit_members'] = basic_memory_sub.subscribers
except:
metrics['subreddit_members'] = 0
return metrics
except Exception as e:
print(f"Reddit API error: {e}")
return {'error': str(e)}
def get_youtube_metrics(self):
"""Get YouTube channel metrics"""
try:
# Get channel statistics
channel_response = self.youtube.channels().list(
part='statistics,snippet',
forUsername=self.youtube_channel
).execute()
if not channel_response['items']:
# Try by channel handle
search_response = self.youtube.search().list(
part='snippet',
q=f'@{self.youtube_channel}',
type='channel',
maxResults=1
).execute()
if search_response['items']:
channel_id = search_response['items'][0]['snippet']['channelId']
channel_response = self.youtube.channels().list(
part='statistics,snippet',
id=channel_id
).execute()
if channel_response['items']:
stats = channel_response['items'][0]['statistics']
return {
'subscribers': int(stats.get('subscriberCount', 0)),
'total_views': int(stats.get('viewCount', 0)),
'video_count': int(stats.get('videoCount', 0))
}
else:
return {'error': 'Channel not found'}
except Exception as e:
print(f"YouTube API error: {e}")
return {'error': str(e)}
def create_discord_embed(self, current_metrics, previous_metrics):
"""Create beautiful Discord embed with all metrics and growth indicators"""
github_data = current_metrics.get('github', {})
reddit_data = current_metrics.get('reddit', {})
youtube_data = current_metrics.get('youtube', {})
prev_github = previous_metrics.get('github', {})
prev_reddit = previous_metrics.get('reddit', {})
prev_youtube = previous_metrics.get('youtube', {})
# Calculate changes
star_change, star_dir = self.calculate_change(github_data.get('stars', 0), prev_github, 'stars')
sub_change, sub_dir = self.calculate_change(youtube_data.get('subscribers', 0), prev_youtube, 'subscribers')
view_change, view_dir = self.calculate_change(youtube_data.get('total_views', 0), prev_youtube, 'total_views')
reddit_change, reddit_dir = self.calculate_change(reddit_data.get('total_mentions', 0), prev_reddit, 'total_mentions')
member_change, member_dir = self.calculate_change(reddit_data.get('subreddit_members', 0), prev_reddit, 'subreddit_members')
# Calculate total reach
total_reach = (
github_data.get('traffic_unique', 0) +
reddit_data.get('total_mentions', 0) * 100 +
youtube_data.get('total_views', 0)
)
embed = {
"title": "🚀 Basic Memory Daily Traction Report",
"description": f"📅 {datetime.now().strftime('%A, %B %d, %Y')}",
"color": 0x00ff88,
"fields": [
{
"name": "⭐ GitHub Metrics",
"value": f"""
**Stars:** {github_data.get('stars', 'N/A')} {star_dir} {self.format_change(star_change, star_dir)}
**Forks:** {github_data.get('forks', 'N/A')} 🍴
**Traffic:** {github_data.get('traffic_unique', 'N/A')} unique visitors 👀
**Issues:** {github_data.get('recent_issues', 0)} new today 🐛
""".strip(),
"inline": True
},
{
"name": "🗨️ Reddit Activity",
"value": f"""
**Mentions:** {reddit_data.get('total_mentions', 'N/A')} {reddit_dir} {self.format_change(reddit_change, reddit_dir)}
**r/BasicMemory:** {reddit_data.get('subreddit_members', 'N/A')} {member_dir} {self.format_change(member_change, member_dir)}
**Hot Posts:** {len(reddit_data.get('top_posts', []))} trending 🔥
""".strip(),
"inline": True
},
{
"name": "📺 YouTube Stats",
"value": f"""
**Subscribers:** {youtube_data.get('subscribers', 'N/A')} {sub_dir} {self.format_change(sub_change, sub_dir)}
**Total Views:** {youtube_data.get('total_views', 'N/A'):,} {view_dir} {self.format_change(view_change, view_dir)}
**Videos:** {youtube_data.get('video_count', 'N/A')} 🎬
""".strip(),
"inline": True
}
],
"footer": {
"text": f"🤖 Automated by Basic Memory • Daily Reach: {total_reach:,}"
},
"timestamp": datetime.now().isoformat()
}
# Add top Reddit posts if available
if reddit_data.get('top_posts'):
top_post = reddit_data['top_posts'][0]
embed["fields"].append({
"name": "🔥 Top Reddit Post",
"value": f"**{top_post['title']}**\n📊 {top_post['score']} upvotes • 💬 {top_post['num_comments']} comments\n📍 r/{top_post['subreddit']}",
"inline": False
})
return embed
def send_discord_report(self, embed):
"""Send the report to Discord"""
try:
payload = {"embeds": [embed]}
response = requests.post(self.discord_webhook, json=payload)
if response.status_code == 204:
print("✅ Discord report sent successfully!")
return True
else:
print(f"❌ Discord webhook failed: {response.status_code}")
print(response.text)
return False
except Exception as e:
print(f"Discord send error: {e}")
return False
def run_daily_report(self):
"""Main function to generate and send daily report"""
print("🚀 Starting Basic Memory Daily Traction Report...")
# Load previous metrics
print("📊 Loading previous metrics...")
previous_metrics = self.get_previous_metrics()
# Collect current metrics
print("📊 Collecting GitHub metrics...")
github_data = self.get_github_metrics()
print("🗨️ Collecting Reddit metrics...")
reddit_data = self.get_reddit_metrics()
print("📺 Collecting YouTube metrics...")
youtube_data = self.get_youtube_metrics()
# Combine current metrics
current_metrics = {
'github': github_data,
'reddit': reddit_data,
'youtube': youtube_data
}
# Create and send report
print("🎨 Creating Discord embed with growth tracking...")
embed = self.create_discord_embed(current_metrics, previous_metrics.get('metrics', {}))
print("📤 Sending to Discord...")
success = self.send_discord_report(embed)
# Save current metrics for tomorrow
print("💾 Saving metrics for tomorrow's comparison...")
self.save_current_metrics(current_metrics)
if success:
print("🎉 Daily traction report completed successfully!")
else:
print("😞 Report failed to send")
# Print summary for GitHub Actions logs
print(f"""
📊 DAILY SUMMARY:
⭐ GitHub Stars: {github_data.get('stars', 'Error')}
👥 Reddit Mentions: {reddit_data.get('total_mentions', 'Error')}
📺 YouTube Subscribers: {youtube_data.get('subscribers', 'Error')}
📺 YouTube Views: {youtube_data.get('total_views', 'Error')}
""")
if __name__ == "__main__":
tracker = BasicMemoryTracker()
tracker.run_daily_report()
@@ -0,0 +1,4 @@
requests==2.31.0
praw==7.7.1
google-api-python-client==2.108.0
python-dateutil==2.8.2
+2 -5
View File
@@ -3,9 +3,6 @@
# 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:
@@ -20,7 +17,7 @@ test: test-unit test-int
# Lint and fix code
lint:
uv run ruff check . --fix
ruff check . --fix
# Type check code
type-check:
@@ -182,4 +179,4 @@ beta version:
# List all available recipes
default:
@just --list
@just --list
+3 -3
View File
@@ -30,7 +30,7 @@ dependencies = [
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
"fastmcp==2.10.2",
"fastmcp>=2.3.4",
"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"
addopts = "--cov=basic_memory --cov-report term-missing -ra -q"
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
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
Basic Memory Daily Traction Report - Enhanced with Growth Tracking
Automated tracking across GitHub, Reddit, YouTube with daily change indicators
"""
import os
import requests
import json
from datetime import datetime, timedelta
import praw
from googleapiclient.discovery import build
from dateutil import parser
import base64
class BasicMemoryTracker:
def __init__(self):
self.github_token = os.getenv('GITHUB_TOKEN')
self.discord_webhook = os.getenv('DISCORD_WEBHOOK')
self.youtube_api_key = os.getenv('YOUTUBE_API_KEY')
# Reddit setup
self.reddit = praw.Reddit(
client_id=os.getenv('REDDIT_CLIENT_ID'),
client_secret=os.getenv('REDDIT_SECRET'),
user_agent='BasicMemoryTracker:v1.0'
)
# YouTube setup
self.youtube = build('youtube', 'v3', developerKey=self.youtube_api_key)
self.repo_owner = 'basicmachines-co'
self.repo_name = 'basic-memory'
self.youtube_channel = 'basicmachines-co'
self.metrics_file = 'data/daily_metrics.json'
def get_previous_metrics(self):
"""Get yesterday's metrics from GitHub repo storage"""
try:
headers = {'Authorization': f'token {self.github_token}'}
url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}'
response = requests.get(url, headers=headers)
if response.status_code == 200:
file_data = response.json()
content = base64.b64decode(file_data['content']).decode('utf-8')
return json.loads(content)
else:
print("📝 No previous metrics found - this is the first run!")
return {}
except Exception as e:
print(f"⚠️ Could not load previous metrics: {e}")
return {}
def save_current_metrics(self, metrics):
"""Save today's metrics to GitHub repo for tomorrow's comparison"""
try:
headers = {'Authorization': f'token {self.github_token}'}
# Prepare data
metrics_data = {
'date': datetime.now().isoformat(),
'metrics': metrics
}
content = json.dumps(metrics_data, indent=2)
encoded_content = base64.b64encode(content.encode()).decode()
# Check if file exists
file_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/contents/{self.metrics_file}'
existing_response = requests.get(file_url, headers=headers)
payload = {
'message': f'📊 Daily metrics update - {datetime.now().strftime("%Y-%m-%d")}',
'content': encoded_content
}
if existing_response.status_code == 200:
# File exists, update it
payload['sha'] = existing_response.json()['sha']
response = requests.put(file_url, headers=headers, json=payload)
else:
# File doesn't exist, create it
response = requests.put(file_url, headers=headers, json=payload)
if response.status_code in [200, 201]:
print("✅ Metrics saved for tomorrow's comparison!")
else:
print(f"⚠️ Failed to save metrics: {response.status_code}")
except Exception as e:
print(f"⚠️ Error saving metrics: {e}")
def calculate_change(self, current, previous, key):
"""Calculate the change between current and previous values"""
if not previous or key not in previous:
return 0, "🆕"
change = current - previous[key]
if change > 0:
return change, "📈"
elif change < 0:
return abs(change), "📉"
else:
return 0, "➡️"
def format_change(self, change, direction):
"""Format the change indicator for display"""
if direction == "🆕":
return "🆕"
elif direction == "📈":
return f"(+{change})"
elif direction == "📉":
return f"(-{change})"
else:
return "(±0)"
def get_github_metrics(self):
"""Get GitHub repository metrics"""
try:
headers = {'Authorization': f'token {self.github_token}'}
# Repository stats
repo_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}'
repo_response = requests.get(repo_url, headers=headers)
repo_data = repo_response.json()
# Traffic stats (requires push access)
traffic_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/traffic/views'
traffic_response = requests.get(traffic_url, headers=headers)
traffic_data = traffic_response.json() if traffic_response.status_code == 200 else {}
# Recent issues
issues_url = f'https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/issues'
issues_response = requests.get(issues_url, headers=headers)
issues_data = issues_response.json() if issues_response.status_code == 200 else []
return {
'stars': repo_data.get('stargazers_count', 0),
'forks': repo_data.get('forks_count', 0),
'watchers': repo_data.get('watchers_count', 0),
'open_issues': repo_data.get('open_issues_count', 0),
'traffic_views': traffic_data.get('count', 0),
'traffic_unique': traffic_data.get('uniques', 0),
'recent_issues': len([i for i in issues_data if
parser.parse(i['created_at']).date() >= (datetime.now() - timedelta(days=1)).date()])
}
except Exception as e:
print(f"GitHub API error: {e}")
return {'error': str(e)}
def get_reddit_metrics(self):
"""Get Reddit metrics for Basic Memory mentions"""
try:
metrics = {
'total_mentions': 0,
'subreddit_members': 0,
'top_posts': [],
'hot_discussions': []
}
# Search for Basic Memory mentions
search_results = list(self.reddit.subreddit('all').search(
'Basic Memory', time_filter='day', limit=25
))
metrics['total_mentions'] = len(search_results)
# Get top posts
for post in search_results[:3]:
metrics['top_posts'].append({
'title': post.title[:50] + '...' if len(post.title) > 50 else post.title,
'score': post.score,
'subreddit': post.subreddit.display_name,
'num_comments': post.num_comments
})
# Check r/BasicMemory if it exists
try:
basic_memory_sub = self.reddit.subreddit('BasicMemory')
metrics['subreddit_members'] = basic_memory_sub.subscribers
except:
metrics['subreddit_members'] = 0
return metrics
except Exception as e:
print(f"Reddit API error: {e}")
return {'error': str(e)}
def get_youtube_metrics(self):
"""Get YouTube channel metrics"""
try:
# Try to find channel by handle first
search_response = self.youtube.search().list(
part='snippet',
q='basicmachines-co',
type='channel',
maxResults=5
).execute()
channel_id = None
if search_response.get('items'):
# Look for exact match or best match
for item in search_response['items']:
channel_title = item['snippet']['title'].lower()
if 'basic' in channel_title and 'machine' in channel_title:
channel_id = item['snippet']['channelId']
break
# If no exact match, use first result
if not channel_id and search_response['items']:
channel_id = search_response['items'][0]['snippet']['channelId']
if channel_id:
# Get channel statistics
channel_response = self.youtube.channels().list(
part='statistics,snippet',
id=channel_id
).execute()
if channel_response.get('items'):
stats = channel_response['items'][0]['statistics']
return {
'subscribers': int(stats.get('subscriberCount', 0)),
'total_views': int(stats.get('viewCount', 0)),
'video_count': int(stats.get('videoCount', 0))
}
# Fallback: return placeholder data
print("⚠️ YouTube channel not found, using placeholder data")
return {
'subscribers': 0,
'total_views': 0,
'video_count': 0
}
except Exception as e:
print(f"YouTube API error: {e}")
return {
'subscribers': 0,
'total_views': 0,
'video_count': 0
}
def create_discord_embed(self, current_metrics, previous_metrics):
"""Create beautiful Discord embed with all metrics and growth indicators"""
github_data = current_metrics.get('github', {})
reddit_data = current_metrics.get('reddit', {})
youtube_data = current_metrics.get('youtube', {})
prev_github = previous_metrics.get('github', {})
prev_reddit = previous_metrics.get('reddit', {})
prev_youtube = previous_metrics.get('youtube', {})
# Calculate changes
star_change, star_dir = self.calculate_change(github_data.get('stars', 0), prev_github, 'stars')
sub_change, sub_dir = self.calculate_change(youtube_data.get('subscribers', 0), prev_youtube, 'subscribers')
view_change, view_dir = self.calculate_change(youtube_data.get('total_views', 0), prev_youtube, 'total_views')
reddit_change, reddit_dir = self.calculate_change(reddit_data.get('total_mentions', 0), prev_reddit, 'total_mentions')
member_change, member_dir = self.calculate_change(reddit_data.get('subreddit_members', 0), prev_reddit, 'subreddit_members')
# Calculate total reach
total_reach = (
github_data.get('traffic_unique', 0) +
reddit_data.get('total_mentions', 0) * 100 +
youtube_data.get('total_views', 0)
)
embed = {
"title": "🚀 Basic Memory Daily Traction Report",
"description": f"📅 {datetime.now().strftime('%A, %B %d, %Y')}",
"color": 0x00ff88,
"fields": [
{
"name": "⭐ GitHub Metrics",
"value": f"""
**Stars:** {github_data.get('stars', 'N/A')} {star_dir} {self.format_change(star_change, star_dir)}
**Forks:** {github_data.get('forks', 'N/A')} 🍴
**Traffic:** {github_data.get('traffic_unique', 'N/A')} unique visitors 👀
**Issues:** {github_data.get('recent_issues', 0)} new today 🐛
""".strip(),
"inline": True
},
{
"name": "🗨️ Reddit Activity",
"value": f"""
**Mentions:** {reddit_data.get('total_mentions', 'N/A')} {reddit_dir} {self.format_change(reddit_change, reddit_dir)}
**r/BasicMemory:** {reddit_data.get('subreddit_members', 'N/A')} {member_dir} {self.format_change(member_change, member_dir)}
**Hot Posts:** {len(reddit_data.get('top_posts', []))} trending 🔥
""".strip(),
"inline": True
},
{
"name": "📺 YouTube Stats",
"value": f"""
**Subscribers:** {youtube_data.get('subscribers', 'N/A')} {sub_dir} {self.format_change(sub_change, sub_dir)}
**Total Views:** {youtube_data.get('total_views', 'N/A')} {view_dir} {self.format_change(view_change, view_dir)}
**Videos:** {youtube_data.get('video_count', 'N/A')} 🎬
""".strip(),
"inline": True
}
],
"footer": {
"text": f"🤖 Automated by Basic Memory • Daily Reach: {total_reach}"
},
"timestamp": datetime.now().isoformat()
}
# Add top Reddit posts if available
if reddit_data.get('top_posts'):
top_post = reddit_data['top_posts'][0]
embed["fields"].append({
"name": "🔥 Top Reddit Post",
"value": f"**{top_post['title']}**\n📊 {top_post['score']} upvotes • 💬 {top_post['num_comments']} comments\n📍 r/{top_post['subreddit']}",
"inline": False
})
return embed
def send_discord_report(self, embed):
"""Send the report to Discord"""
try:
payload = {"embeds": [embed]}
response = requests.post(self.discord_webhook, json=payload)
if response.status_code == 204:
print("✅ Discord report sent successfully!")
return True
else:
print(f"❌ Discord webhook failed: {response.status_code}")
print(response.text)
return False
except Exception as e:
print(f"Discord send error: {e}")
return False
def run_daily_report(self):
"""Main function to generate and send daily report"""
print("🚀 Starting Basic Memory Daily Traction Report...")
# Load previous metrics
print("📊 Loading previous metrics...")
previous_metrics = self.get_previous_metrics()
# Collect current metrics
print("📊 Collecting GitHub metrics...")
github_data = self.get_github_metrics()
print("🗨️ Collecting Reddit metrics...")
reddit_data = self.get_reddit_metrics()
print("📺 Collecting YouTube metrics...")
youtube_data = self.get_youtube_metrics()
# Combine current metrics
current_metrics = {
'github': github_data,
'reddit': reddit_data,
'youtube': youtube_data
}
# Create and send report
print("🎨 Creating Discord embed with growth tracking...")
embed = self.create_discord_embed(current_metrics, previous_metrics.get('metrics', {}))
print("📤 Sending to Discord...")
success = self.send_discord_report(embed)
# Save current metrics for tomorrow
print("💾 Saving metrics for tomorrow's comparison...")
self.save_current_metrics(current_metrics)
if success:
print("🎉 Daily traction report completed successfully!")
else:
print("😞 Report failed to send")
# Print summary for GitHub Actions logs
print(f"""
📊 DAILY SUMMARY:
⭐ GitHub Stars: {github_data.get('stars', 'Error')}
👥 Reddit Mentions: {reddit_data.get('total_mentions', 'Error')}
📺 YouTube Subscribers: {youtube_data.get('subscribers', 'Error')}
📺 YouTube Views: {youtube_data.get('total_views', 'Error')}
""")
if __name__ == "__main__":
tracker = BasicMemoryTracker()
tracker.run_daily_report()
+4
View File
@@ -0,0 +1,4 @@
requests==2.31.0
praw==7.7.1
google-api-python-client==2.108.0
python-dateutil==2.8.2
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.14.3"
__version__ = "0.14.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+1 -3
View File
@@ -8,19 +8,17 @@ 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)
+1 -4
View File
@@ -20,18 +20,15 @@ from basic_memory.api.routers import (
search,
prompt_router,
)
from basic_memory.config import ConfigManager
from basic_memory.config import app_config
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 ConfigManager
from basic_memory.config import app_config
from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
router = APIRouter(prefix="/management", tags=["management"])
@@ -41,8 +41,6 @@ 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")
+13 -21
View File
@@ -1,6 +1,5 @@
"""Router for project management."""
import os
from fastapi import APIRouter, HTTPException, Path, Body
from typing import Optional
@@ -33,47 +32,40 @@ async def get_project_info(
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New absolute path for the project"),
project_name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New 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:
name: The name of the project to update
path: Optional new absolute path for the project
project_name: The name of the project to update
path: Optional new path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
"""
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")
try: # pragma: no cover
# Get original project info for the response
old_project_info = ProjectItem(
name=name,
path=project_service.projects.get(name, ""),
name=project_name,
path=project_service.projects.get(project_name, ""),
)
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)
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
# Get updated project info
updated_path = path if path else project_service.projects.get(name, "")
updated_path = path if path else project_service.projects.get(project_name, "")
return ProjectStatusResponse(
message=f"Project '{name}' updated successfully",
message=f"Project '{project_name}' updated successfully",
status="success",
default=(name == project_service.default_project),
default=(project_name == project_service.default_project),
old_project=old_project_info,
new_project=ProjectItem(name=name, path=updated_path),
new_project=ProjectItem(name=project_name, path=updated_path),
)
except ValueError as e:
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
+3 -3
View File
@@ -2,7 +2,7 @@ from typing import Optional
import typer
from basic_memory.config import get_project_config, ConfigManager
from basic_memory.config import get_project_config
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
+2 -1
View File
@@ -1,9 +1,10 @@
"""CLI commands for basic-memory."""
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
from . import auth, status, sync, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project
__all__ = [
"auth",
"status",
"sync",
"db",
+136
View File
@@ -0,0 +1,136 @@
"""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()
+5 -5
View File
@@ -1,13 +1,14 @@
"""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 ConfigManager, BasicMemoryConfig, save_basic_memory_config
from basic_memory.config import app_config, config_manager
@app.command()
@@ -17,8 +18,6 @@ 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
@@ -28,8 +27,9 @@ def reset(
logger.info(f"Database file deleted: {db_path}")
# Reset project configuration
config = BasicMemoryConfig()
save_basic_memory_config(config_manager.config_file, config)
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")
# 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 get_project_config
from basic_memory.config import config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
@@ -19,7 +19,6 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
@@ -50,7 +49,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 get_project_config
from basic_memory.config import config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
@@ -19,7 +19,6 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
@@ -43,7 +42,6 @@ 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 get_project_config
from basic_memory.config import config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
@@ -19,7 +19,6 @@ 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,7 +41,6 @@ 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 get_project_config
from basic_memory.config import config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
@@ -19,7 +19,6 @@ console = Console()
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
@@ -47,7 +46,6 @@ 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())
@@ -76,8 +74,7 @@ def memory_json(
Panel(
f"[green]Import complete![/green]\n\n"
f"Created {result.entities} entities\n"
f"Added {result.relations} relations\n"
f"Skipped {result.skipped_entities} entities\n",
f"Added {result.relations} relations",
expand=False,
)
)
+15 -3
View File
@@ -4,7 +4,6 @@ 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
@@ -35,13 +34,26 @@ 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()
-41
View File
@@ -23,7 +23,6 @@ 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()
@@ -149,46 +148,6 @@ 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"),
+1 -4
View File
@@ -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 ConfigManager, get_project_config
from basic_memory.config import config, app_config
from basic_memory.repository import ProjectRepository
from basic_memory.sync.sync_service import SyncReport
@@ -126,9 +126,6 @@ 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
)
+2 -10
View File
@@ -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 ConfigManager, get_project_config
from basic_memory.config import config
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Project
@@ -29,6 +29,7 @@ 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()
@@ -41,8 +42,6 @@ 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
)
@@ -97,7 +96,6 @@ 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
@@ -126,7 +124,6 @@ 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:
@@ -161,9 +158,6 @@ 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
)
@@ -218,8 +212,6 @@ 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}")
+1
View File
@@ -4,6 +4,7 @@ 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,
+31 -46
View File
@@ -74,12 +74,6 @@ 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",
@@ -130,7 +124,6 @@ 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
@@ -169,21 +162,20 @@ class ConfigManager:
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
@property
def config(self) -> BasicMemoryConfig:
"""Get configuration, loading it lazily if needed."""
return self.load_config()
# Load or create configuration
self.config = 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.exception(f"Failed to load config: {e}")
raise e
logger.error(f"Failed to load config: {e}")
config = BasicMemoryConfig()
self.save_config(config)
return config
else:
config = BasicMemoryConfig()
self.save_config(config)
@@ -191,7 +183,10 @@ class ConfigManager:
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
save_basic_memory_config(self.config_file, config)
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}")
@property
def projects(self) -> Dict[str, str]:
@@ -213,10 +208,8 @@ class ConfigManager:
project_path = Path(path)
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
# Load config, modify it, and save it
config = self.load_config()
config.projects[name] = str(project_path)
self.save_config(config)
self.config.projects[name] = str(project_path)
self.save_config(self.config)
return ProjectConfig(name=name, home=project_path)
def remove_project(self, name: str) -> None:
@@ -226,13 +219,11 @@ class ConfigManager:
if not project_name: # pragma: no cover
raise ValueError(f"Project '{name}' not found")
# Load config, check, modify, and save
config = self.load_config()
if project_name == config.default_project: # pragma: no cover
if project_name == self.config.default_project: # pragma: no cover
raise ValueError(f"Cannot remove the default project '{name}'")
del config.projects[name]
self.save_config(config)
del self.config.projects[name]
self.save_config(self.config)
def set_default_project(self, name: str) -> None:
"""Set the default project."""
@@ -240,18 +231,15 @@ class ConfigManager:
if not project_name: # pragma: no cover
raise ValueError(f"Project '{name}' not found")
# Load config, modify, and save
config = self.load_config()
config.default_project = name
self.save_config(config)
self.config.default_project = name
self.save_config(self.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)
app_config = self.config
for project_name, path in app_config.projects.items():
if project_permalink == generate_permalink(project_name):
return project_name, path
for name, path in app_config.projects.items():
if project_permalink == generate_permalink(name):
return name, path
return None, None
@@ -264,7 +252,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
actual_project_name = None
# load the config from file
config_manager = ConfigManager()
global app_config
app_config = config_manager.load_config()
# Get project name from environment variable
@@ -294,12 +282,14 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
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}")
# 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 update_current_project(project_name: str) -> None:
@@ -351,17 +341,12 @@ 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.config.log_level,
log_level=config_manager.load_config().log_level,
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
console=console_logging,
console=False,
)
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
+6 -2
View File
@@ -4,7 +4,7 @@ from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.config import BasicMemoryConfig
from alembic import command
from alembic.config import Config
@@ -88,6 +88,7 @@ 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
@@ -97,7 +98,10 @@ async def get_or_create_db(
# Run migrations automatically unless explicitly disabled
if ensure_migrations:
app_config = ConfigManager().config
if app_config is None:
from basic_memory.config import app_config as global_app_config
app_config = global_app_config
await run_migrations(app_config, db_type)
# These checks should never fail since we just created the engine and session maker
+2 -3
View File
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import (
import pathlib
from basic_memory import db
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
from basic_memory.config import ProjectConfig, BasicMemoryConfig
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,7 +297,6 @@ ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
async def get_sync_service(
app_config: AppConfigDep,
entity_service: EntityServiceDep,
entity_parser: EntityParserDep,
entity_repository: EntityRepositoryDep,
+9 -19
View File
@@ -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 iteratively to handle deep conversations.
"""Traverse message tree and return messages in order.
Args:
mapping: Message mapping.
@@ -204,29 +204,19 @@ class ChatGPTImporter(Importer[ChatImportResult]):
List of message data.
"""
messages = []
if not root_id:
return messages
node = mapping.get(root_id) if root_id else None
# 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
while node:
if node["id"] not in seen and node.get("message"):
seen.add(node["id"])
messages.append(node["message"])
# Add children to stack in reverse order to maintain conversation flow
# Follow children
children = node.get("children", [])
for child_id in reversed(children):
stack.append(child_id)
for child_id in children:
child_msgs = self._traverse_messages(mapping, child_id, seen)
messages.extend(child_msgs)
break # Don't follow siblings
return messages
@@ -3,7 +3,7 @@
import logging
from typing import Any, Dict, List
from basic_memory.config import get_project_config
from basic_memory.config import 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,12 +27,10 @@ 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
@@ -43,13 +41,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
for line in source_data:
data = line
if data["type"] == "entity":
# 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
entities[data["name"]] = data
elif data["type"] == "relation":
# Store relation with its source entity
source = data.get("from") or data.get("from_id")
@@ -65,31 +57,25 @@ 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_type
entity_type_dir = base_path / entity_data["entityType"]
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_type,
"type": entity_data["entityType"],
"title": name,
"permalink": f"{entity_type}/{name}",
"permalink": f"{entity_data['entityType']}/{name}",
}
),
content=f"# {name}\n",
observations=[Observation(content=obs) for obs in observations],
observations=[Observation(content=obs) for obs in entity_data["observations"]],
relations=entity_relations.get(name, []),
)
# Write entity file
file_path = base_path / f"{entity_type}/{name}.md"
file_path = base_path / f"{entity_data['entityType']}/{name}.md"
await self.write_entity(entity, file_path)
entities_created += 1
@@ -100,7 +86,6 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
success=True,
entities=entities_created,
relations=relations_count,
skipped_entities=skipped_entities,
)
except Exception as e: # pragma: no cover
+2 -22
View File
@@ -1,28 +1,8 @@
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 = create_client()
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
+270
View File
@@ -0,0 +1,270 @@
"""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")
@@ -0,0 +1,321 @@
"""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",
)
+4 -6
View File
@@ -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, ConfigManager
from basic_memory.config import ProjectConfig, get_project_config, config_manager
@dataclass
@@ -23,7 +23,7 @@ class ProjectSession:
current_project: Optional[str] = None
default_project: Optional[str] = None
def initialize(self, default_project: str) -> "ProjectSession":
def initialize(self, default_project: str) -> None:
"""Set the default project from config on startup.
Args:
@@ -32,7 +32,6 @@ 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.
@@ -73,7 +72,7 @@ class ProjectSession:
via CLI or API to ensure MCP session stays in sync.
"""
# Reload config to get latest default project
current_config = ConfigManager().config
current_config = config_manager.load_config()
new_default = current_config.default_project
# Reinitialize with new default
@@ -103,8 +102,7 @@ def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
return project
current_project = session.get_current_project()
active_project = get_project_config(current_project)
return active_project
return get_project_config(current_project)
def add_project_metadata(result: str, project_name: str) -> str:
+2
View File
@@ -10,10 +10,12 @@ 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",
]
+112
View File
@@ -0,0 +1,112 @@
"""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'
"""
+71 -8
View File
@@ -7,10 +7,25 @@ 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 ConfigManager
from basic_memory.config import app_config
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
@@ -21,11 +36,7 @@ class AppContext:
@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
""" """
# defer import so tests can monkeypatch
from basic_memory.mcp.project_session import session
app_config = ConfigManager().config
"""Manage application lifecycle with type-safe context"""
# Initialize on startup (now returns migration_manager)
migration_manager = await initialize_app(app_config)
@@ -39,8 +50,60 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
pass
# Create the shared server instance with custom Stytch auth
# 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
mcp = FastMCP(
name="Basic Memory",
lifespan=app_lifespan,
auth=auth_provider,
)
@@ -0,0 +1,463 @@
"""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")
+14 -26
View File
@@ -11,7 +11,6 @@ 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(
@@ -48,7 +47,18 @@ async def _detect_cross_project_move_attempt(
identifier, destination_path, current_project, matching_project
)
# No other cross-project patterns detected
# Check if the destination path looks like it might be trying to reference another project
# (e.g., contains common project-like patterns)
if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]):
# This might be a cross-project attempt, but we can't be sure
# Return a general guidance message
available_projects = [
p.name for p in project_list.projects if p.name != current_project
]
if available_projects:
return _format_potential_cross_project_guidance(
identifier, destination_path, current_project, available_projects
)
except Exception as e:
# If we can't detect, don't interfere with normal error handling
@@ -99,7 +109,7 @@ def _format_cross_project_error_response(
```
## Available projects:
Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
Use `list_memory_projects()` to see all available projects and `switch_project("project-name")` to change projects.
""").strip()
@@ -143,7 +153,7 @@ def _format_potential_cross_project_guidance(
### To see all projects:
```
list_projects()
list_memory_projects()
```
""").strip()
@@ -394,28 +404,6 @@ 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
@@ -5,7 +5,6 @@ and manage project context during conversations.
"""
from textwrap import dedent
from typing import Optional
from fastmcp import Context
from loguru import logger
@@ -20,9 +19,7 @@ from basic_memory.utils import generate_permalink
@mcp.tool("list_memory_projects")
async def list_memory_projects(
ctx: Context | None = None, _compatibility: Optional[str] = None
) -> str:
async def list_memory_projects(ctx: Context | None = None) -> str:
"""List all available projects with their status.
Shows all Basic Memory projects that are available, indicating which one
@@ -147,7 +144,7 @@ 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_memory_projects()` to see valid project names
1. **Check available projects**: Use `list_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
@@ -162,9 +159,7 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
@mcp.tool()
async def get_current_project(
ctx: Context | None = None, _compatibility: Optional[str] = None
) -> str:
async def get_current_project(ctx: Context | None = None) -> str:
"""Show the currently active project and basic stats.
Displays which project is currently active and provides basic information
@@ -17,7 +17,6 @@ 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):
@@ -189,21 +188,6 @@ 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))
-12
View File
@@ -11,7 +11,6 @@ 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(
@@ -68,17 +67,6 @@ async def read_note(
# 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}")
+8 -8
View File
@@ -54,7 +54,7 @@ def _format_search_error_response(error_message: str, query: str, search_type: s
```
## Alternative search strategies:
- Break into simpler terms: `search_notes("{" ".join(clean_query.split()[:2])}")`
- 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()
@@ -67,13 +67,13 @@ def _format_search_error_response(error_message: str, query: str, search_type: s
The current project is not accessible or doesn't exist: {error_message}
## How to resolve:
1. **Check available projects**: `list_projects()`
1. **Check available projects**: `list_memory_projects()`
2. **Switch to valid project**: `switch_project("valid-project-name")`
3. **Verify project setup**: Ensure your project is properly configured
## Current session info:
- Check current project: `get_current_project()`
- See available projects: `list_projects()`
- See available projects: `list_memory_projects()`
""").strip()
# No results found
@@ -105,7 +105,7 @@ def _format_search_error_response(error_message: str, query: str, search_type: s
- **Permalink search**: `search_notes("{query}", search_type="permalink")` (searches file paths)
4. **Try boolean operators for broader results**:
- OR search: `search_notes("{" OR ".join(query.split()[:3])}")`
- OR search: `search_notes("{' OR '.join(query.split()[:3])}")`
- Remove restrictive terms: Focus on the most important keywords
5. **Use filtering to narrow scope**:
@@ -162,7 +162,7 @@ You don't have permission to search in the current project: {error_message}
3. **Check authentication**: You might need to re-authenticate
## Alternative actions:
- List available projects: `list_projects()`
- List available projects: `list_memory_projects()`
- Switch to accessible project: `switch_project("project-name")`
- Check current project: `get_current_project()`"""
@@ -183,13 +183,13 @@ Error searching for '{query}': {error_message}
- 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])}")`
- **Boolean variations**: `search_notes("{' OR '.join(query.split()[:2])}")`
## 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
- **All projects**: `list_memory_projects()` - Switch to different project if needed
## Search syntax reference:
- **Basic**: `keyword` or `multiple words`
@@ -224,7 +224,7 @@ async def search_notes(
- `search_notes("keyword")` - Find any content containing "keyword"
- `search_notes("exact phrase")` - Search for exact phrase match
### Advanced Boolean Searches
### 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
+2 -3
View File
@@ -4,10 +4,8 @@ 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]:
@@ -15,7 +13,8 @@ def _get_all_projects_status() -> list[str]:
status_lines = []
try:
app_config = ConfigManager().config
from basic_memory.config import app_config
from basic_memory.services.sync_status_service import sync_status_tracker
if app_config.projects:
status_lines.extend(["", "---", "", "**All Projects Status:**"])
+1 -9
View File
@@ -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, validate_project_path
from basic_memory.utils import parse_tags
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -75,14 +75,6 @@ async def write_note(
# 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
+3 -3
View File
@@ -1,6 +1,6 @@
"""Project model for Basic Memory."""
from datetime import datetime, UTC
from datetime import datetime
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=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Define relationships to entities, observations, and relations
@@ -83,21 +83,3 @@ 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
View File
@@ -32,4 +32,3 @@ class EntityImportResult(ImportResult):
entities: int = 0
relations: int = 0
skipped_entities: int = 0
+3 -49
View File
@@ -15,7 +15,6 @@ 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
@@ -45,39 +44,6 @@ 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:
@@ -88,30 +54,18 @@ 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 == file_path_str:
if not existing or existing.file_path == str(file_path):
return desired_permalink
# For existing files, try to find current permalink
existing = await self.repository.get_by_file_path(file_path_str)
existing = await self.repository.get_by_file_path(str(file_path))
if existing:
return existing.permalink
@@ -121,7 +75,7 @@ class EntityService(BaseService[EntityModel]):
else:
desired_permalink = generate_permalink(file_path)
# Make unique if needed - enhanced to handle character conflicts
# Make unique if needed
permalink = desired_permalink
suffix = 1
while await self.repository.get_by_permalink(permalink):
@@ -5,12 +5,14 @@ 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
@@ -68,6 +70,63 @@ 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,
):
@@ -127,6 +186,16 @@ 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
@@ -160,7 +229,13 @@ 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:
@@ -0,0 +1,168 @@
"""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()
+28 -85
View File
@@ -9,6 +9,7 @@ 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 (
@@ -17,8 +18,9 @@ from basic_memory.schemas import (
ProjectStatistics,
SystemStatus,
)
from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
from basic_memory.config import WATCH_STATUS_JSON
from basic_memory.utils import generate_permalink
from basic_memory.config import config_manager
class ProjectService:
@@ -31,24 +33,6 @@ 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.
@@ -56,7 +40,7 @@ class ProjectService:
Returns:
Dict mapping project names to their file paths
"""
return self.config_manager.projects
return config_manager.projects
@property
def default_project(self) -> str:
@@ -65,7 +49,7 @@ class ProjectService:
Returns:
The name of the default project
"""
return self.config_manager.default_project
return config_manager.default_project
@property
def current_project(self) -> str:
@@ -74,7 +58,7 @@ class ProjectService:
Returns:
The name of the current project
"""
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
async def list_projects(self) -> Sequence[Project]:
return await self.repository.find_all()
@@ -103,7 +87,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 = self.config_manager.add_project(name, resolved_path)
project_config = config_manager.add_project(name, resolved_path)
# Then add to database
project_data = {
@@ -119,7 +103,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)
self.config_manager.set_default_project(name)
config_manager.set_default_project(name)
logger.info(f"Project '{name}' set as default")
logger.info(f"Project '{name}' added at {resolved_path}")
@@ -137,7 +121,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)
self.config_manager.remove_project(name)
config_manager.remove_project(name)
# Then remove from database
project = await self.repository.get_by_name(name)
@@ -159,7 +143,7 @@ class ProjectService:
raise ValueError("Repository is required for set_default_project")
# First update config file (this will validate the project exists)
self.config_manager.set_default_project(name)
config_manager.set_default_project(name)
# Then update database
project = await self.repository.get_by_name(name)
@@ -212,7 +196,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 = self.config_manager.default_project # pragma: no cover
config_default = 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
@@ -237,7 +221,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 = self.config_manager.projects.copy()
config_projects = config_manager.projects.copy()
updated_config = {}
config_updated = False
@@ -253,9 +237,8 @@ class ProjectService:
# Update the configuration if any changes were made
if config_updated:
config = self.config_manager.load_config()
config.projects = updated_config
self.config_manager.save_config(config)
config_manager.config.projects = updated_config
config_manager.save_config(config_manager.config)
logger.info("Config updated with normalized project names")
# Use the normalized config for further processing
@@ -278,19 +261,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")
self.config_manager.add_project(name, project.path)
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 = self.config_manager.default_project
config_default = 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}'")
self.config_manager.set_default_project(db_default.name)
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)
@@ -309,47 +292,6 @@ 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:
@@ -367,7 +309,7 @@ class ProjectService:
raise ValueError("Repository is required for update_project")
# Validate project exists in config
if name not in self.config_manager.projects:
if name not in config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")
# Get project from database
@@ -381,9 +323,10 @@ class ProjectService:
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
# Update in config
config = self.config_manager.load_config()
config.projects[name] = resolved_path
self.config_manager.save_config(config)
projects = config_manager.config.projects.copy()
projects[name] = resolved_path
config_manager.config.projects = projects
config_manager.save_config(config_manager.config)
# Update in database
project.path = resolved_path
@@ -404,7 +347,7 @@ class ProjectService:
if active_projects:
new_default = active_projects[0]
await self.repository.set_as_default(new_default.id)
self.config_manager.set_default_project(new_default.name)
config_manager.set_default_project(new_default.name)
logger.info(
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
)
@@ -422,9 +365,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 self.config.project
project_name = project_name or config.project
# Get project path from configuration
name, project_path = self.config_manager.get_project(project_name)
name, project_path = config_manager.get_project(project_name)
if not name: # pragma: no cover
raise ValueError(f"Project '{project_name}' not found in configuration")
@@ -450,11 +393,11 @@ class ProjectService:
db_projects_by_permalink = {p.permalink: p for p in db_projects}
# Get default project info
default_project = self.config_manager.default_project
default_project = config_manager.default_project
# Convert config projects to include database info
enhanced_projects = {}
for name, path in self.config_manager.projects.items():
for name, path in config_manager.projects.items():
config_permalink = generate_permalink(name)
db_project = db_projects_by_permalink.get(config_permalink)
enhanced_projects[name] = {
@@ -730,7 +673,7 @@ class ProjectService:
import basic_memory
# Get database information
db_path = self.config_manager.config.database_path
db_path = app_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"
+3 -4
View File
@@ -2,7 +2,7 @@ import asyncio
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.config import config as project_config
from basic_memory.sync import SyncService, WatchService
@@ -11,10 +11,9 @@ async def sync_and_watch(
): # pragma: no cover
"""Run sync and watch service."""
config = get_project_config()
logger.info(f"Starting watch service to sync file changes in dir: {config.home}")
logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
# full sync
await sync_service.sync(config.home)
await sync_service.sync(project_config.home)
# watch changes
await watch_service.run()
+1 -50
View File
@@ -453,36 +453,6 @@ 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}
@@ -507,26 +477,7 @@ class SyncService:
f"new_checksum={new_checksum}"
)
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
updated = await self.entity_repository.update(entity.id, updates)
if updated is None: # pragma: no cover
logger.error(
+4 -107
View File
@@ -4,6 +4,7 @@ import os
import logging
import re
import sys
import unicodedata
from pathlib import Path
from typing import Optional, Protocol, Union, runtime_checkable, List, Any
@@ -143,7 +144,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":
@@ -161,8 +162,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}")
@@ -172,8 +173,6 @@ 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
@@ -181,79 +180,6 @@ 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.
@@ -286,32 +212,3 @@ 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
+158
View File
@@ -0,0 +1,158 @@
"""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
+38 -25
View File
@@ -58,12 +58,16 @@ 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
@@ -89,12 +93,12 @@ async def engine_factory(tmp_path):
@pytest_asyncio.fixture(scope="function")
async def test_project(config_home, engine_factory) -> Project:
async def test_project(tmp_path, engine_factory) -> Project:
"""Create a test project."""
project_data = {
"name": "test-project",
"description": "Project used for integration tests",
"path": str(config_home),
"path": str(tmp_path),
"is_active": True,
"is_default": True,
}
@@ -108,50 +112,55 @@ async def test_project(config_home, 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", autouse=True)
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
@pytest.fixture(scope="function")
def app_config(config_home, test_project, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
# Create a basic config with test-project like unit tests do
projects = {"test-project": str(config_home)}
projects = {test_project.name: str(test_project.path)}
app_config = BasicMemoryConfig(
env="test",
projects=projects,
default_project="test-project",
default_project=test_project.name,
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(scope="function", autouse=True)
def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
@pytest.fixture
def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> 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(scope="function")
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
@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)
return basic_memory.mcp.project_session.session
@pytest.fixture(scope="function", autouse=True)
def project_config(test_project):
@pytest.fixture(scope="function")
def project_config(test_project, monkeypatch):
"""Create test project configuration."""
project_config = ProjectConfig(
@@ -159,6 +168,9 @@ def project_config(test_project):
home=Path(test_project.path),
)
# override config module project config
monkeypatch.setattr("basic_memory.config.config", project_config)
return project_config
@@ -168,10 +180,6 @@ 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
@@ -207,7 +215,7 @@ async def search_service(engine_factory, test_project):
@pytest.fixture(scope="function")
def mcp_server(config_manager, search_service, project_session):
def mcp_server(app_config, search_service):
# Import mcp instance
from basic_memory.mcp.server import mcp as server
@@ -217,6 +225,11 @@ def mcp_server(config_manager, search_service, project_session):
# 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.content) == 1
response = result.content[0].text
assert len(result) == 1
response = result[0].text
assert '"results"' in response # Should contain results structure
assert '"metadata"' in response # Should contain metadata
@@ -82,7 +82,6 @@ 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
)
@@ -102,8 +101,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.content) == 1
response = result.content[0].text
assert len(result) == 1
response = result[0].text
assert '"results": []' in response # Empty results
assert '"total_results": 0' in response # Zero count
assert '"metadata"' in response # But should have metadata
@@ -164,8 +163,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.content) == 1
response = result.content[0].text
assert len(result) == 1
response = result[0].text
# Should find the pattern matches but not the other note
assert '"total_results": 2' in response or '"primary_count": 2' in response
+29 -37
View File
@@ -31,8 +31,8 @@ async def test_delete_note_by_title(mcp_server, app):
"identifier": "Note to Delete",
},
)
assert len(read_result.content) == 1
assert "Note to Delete" in read_result.content[0].text
assert len(read_result) == 1
assert "Note to Delete" in read_result[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.content) == 1
assert delete_result.content[0].type == "text"
assert "true" in delete_result.content[0].text.lower()
assert len(delete_result) == 1
assert delete_result[0].type == "text"
assert "true" in delete_result[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.content) == 1
result_text = read_after_delete.content[0].text
assert len(read_after_delete) == 1
result_text = read_after_delete[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.content) == 1
assert "true" in delete_result.content[0].text.lower()
assert len(delete_result) == 1
assert "true" in delete_result[0].text.lower()
# Verify the note no longer exists by searching
search_result = await client.call_tool(
@@ -99,10 +99,7 @@ async def test_delete_note_by_permalink(mcp_server, app):
)
# Should have no results
assert (
'"results": []' in search_result.content[0].text
or '"results":[]' in search_result.content[0].text
)
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
@pytest.mark.asyncio
@@ -145,8 +142,8 @@ The system handles multiple projects and users."""
"identifier": "Project Management System",
},
)
assert len(read_result.content) == 1
result_text = read_result.content[0].text
assert len(read_result) == 1
result_text = read_result[0].text
assert "Task tracking functionality" in result_text
assert "depends_on" in result_text
@@ -159,7 +156,7 @@ The system handles multiple projects and users."""
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower()
assert "true" in delete_result[0].text.lower()
# Verify the note and all its components are deleted
read_after_delete_2 = await client.call_tool(
@@ -170,8 +167,8 @@ The system handles multiple projects and users."""
)
# Should return "Note Not Found" message
assert len(read_after_delete_2.content) == 1
result_text = read_after_delete_2.content[0].text
assert len(read_after_delete_2) == 1
result_text = read_after_delete_2[0].text
assert "Note Not Found" in result_text
assert "Project Management System" in result_text
@@ -212,9 +209,7 @@ async def test_delete_note_special_characters_in_title(mcp_server, app):
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower(), (
f"Failed to delete note: {title}"
)
assert "true" in delete_result[0].text.lower(), f"Failed to delete note: {title}"
# Verify the note is deleted
read_after_delete = await client.call_tool(
@@ -225,8 +220,8 @@ async def test_delete_note_special_characters_in_title(mcp_server, app):
)
# Should return "Note Not Found" message
assert len(read_after_delete.content) == 1
result_text = read_after_delete.content[0].text
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert title in result_text
@@ -245,8 +240,8 @@ async def test_delete_nonexistent_note(mcp_server, app):
)
# Should return False for unsuccessful deletion
assert len(delete_result.content) == 1
assert "false" in delete_result.content[0].text.lower()
assert len(delete_result) == 1
assert "false" in delete_result[0].text.lower()
@pytest.mark.asyncio
@@ -274,7 +269,7 @@ async def test_delete_note_by_file_path(mcp_server, app):
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower()
assert "true" in delete_result[0].text.lower()
# Verify deletion
read_after_delete = await client.call_tool(
@@ -285,8 +280,8 @@ async def test_delete_note_by_file_path(mcp_server, app):
)
# Should return "Note Not Found" message
assert len(read_after_delete.content) == 1
result_text = read_after_delete.content[0].text
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert "File Path Delete" in result_text
@@ -316,7 +311,7 @@ async def test_delete_note_case_insensitive(mcp_server, app):
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower()
assert "true" in delete_result[0].text.lower()
@pytest.mark.asyncio
@@ -354,7 +349,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app):
)
# Each deletion should be successful
assert "true" in delete_result.content[0].text.lower(), f"Failed to delete {title}"
assert "true" in delete_result[0].text.lower(), f"Failed to delete {title}"
# Verify all notes are deleted by searching
search_result = await client.call_tool(
@@ -365,10 +360,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app):
)
# Should have no results
assert (
'"results": []' in search_result.content[0].text
or '"results":[]' in search_result.content[0].text
)
assert '"results": []' in search_result[0].text or '"results":[]' in search_result[0].text
@pytest.mark.asyncio
@@ -412,7 +404,7 @@ This note contains various Unicode characters:
)
# Should return True for successful deletion
assert "true" in delete_result.content[0].text.lower()
assert "true" in delete_result[0].text.lower()
# Verify deletion
read_after_delete = await client.call_tool(
@@ -423,7 +415,7 @@ This note contains various Unicode characters:
)
# Should return "Note Not Found" message
assert len(read_after_delete.content) == 1
result_text = read_after_delete.content[0].text
assert len(read_after_delete) == 1
result_text = read_after_delete[0].text
assert "Note Not Found" in result_text
assert "Unicode Test Note" in result_text
+28 -28
View File
@@ -35,8 +35,8 @@ async def test_edit_note_append_operation(mcp_server, app):
)
# Should return successful edit summary
assert len(edit_result.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content) == 1
error_text = edit_result.content[0].text
assert len(edit_result) == 1
error_text = edit_result[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.content) == 1
error_text = edit_result.content[0].text
assert len(edit_result) == 1
error_text = edit_result[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.content) == 1
error_text = edit_result.content[0].text
assert len(edit_result) == 1
error_text = edit_result[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.content) == 1
edit_text = edit_result.content[0].text
assert len(edit_result) == 1
edit_text = edit_result[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.content[0].text
content = read_result[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.content[0].text
assert "Edited note (append)" in edit_result1[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.content[0].text
assert "Edited note (append)" in edit_result2[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.content[0].text
assert "Edited note (append)" in edit_result3[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.content[0].text
content = read_result[0].text
assert "Edited by title." in content
assert "Edited by permalink." in content
assert "Edited by folder/title." in content
+20 -20
View File
@@ -54,8 +54,8 @@ async def test_list_directory_basic_operation(mcp_server, app):
)
# Should return formatted directory listing
assert len(list_result.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[0].text
# Should show only Project files
assert "Project Alpha Plan.md" in list_text
+87 -43
View File
@@ -34,8 +34,8 @@ async def test_move_note_basic_operation(mcp_server, app):
)
# Should return successful move message
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content[0].text
content = read_result[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.content[0].text
assert "Note Not Found" in read_original[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.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content[0].text
assert "Moving by permalink" in read_result[0].text
@pytest.mark.asyncio
@@ -150,8 +150,8 @@ This note demonstrates moving complex content."""
)
# Should successfully move
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content[0].text
content = read_result[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.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content[0].text
assert "This will be moved deep" in read_result[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.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content[0].text
assert "Testing special characters in move" in read_result[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.content) == 1
error_message = move_result.content[0].text
assert len(move_result) == 1
error_message = move_result[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.content) == 1
error_message = move_result.content[0].text
assert len(move_result) == 1
error_message = move_result[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.content) == 1
error_message = move_result.content[0].text
assert len(move_result) == 1
error_message = move_result[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.content) > 0
assert "Searchable Note" in search_before.content[0].text
assert len(search_before) > 0
assert "Searchable Note" in search_before[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.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[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.content) > 0
search_text = search_after.content[0].text
assert len(search_after) > 0
search_text = search_after[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.content) > 0
assert len(search_by_path) > 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.content) == 1
assert "✅ Note moved successfully" in move1.content[0].text
assert len(move1) == 1
assert "✅ Note moved successfully" in move1[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.content) == 1
assert "✅ Note moved successfully" in move2.content[0].text
assert len(move2) == 1
assert "✅ Note moved successfully" in move2[0].text
# Test moving by folder/title format
move3 = await client.call_tool(
@@ -495,18 +495,18 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app):
"destination_path": "moved/folder-title-moved.md",
},
)
assert len(move3.content) == 1
assert "✅ Note moved successfully" in move3.content[0].text
assert len(move3) == 1
assert "✅ Note moved successfully" in move3[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.content[0].text
assert "Move by title" in read1[0].text
read2 = await client.call_tool("read_note", {"identifier": "moved/permalink-moved.md"})
assert "Move by permalink" in read2.content[0].text
assert "Move by permalink" in read2[0].text
read3 = await client.call_tool("read_note", {"identifier": "moved/folder-title-moved.md"})
assert "Move by folder/title" in read3.content[0].text
assert "Move by folder/title" in read3[0].text
@pytest.mark.asyncio
@@ -545,8 +545,8 @@ async def test_move_note_cross_project_detection(mcp_server, app):
)
# Should detect cross-project attempt and provide helpful guidance
assert len(move_result.content) == 1
error_message = move_result.content[0].text
assert len(move_result) == 1
error_message = move_result[0].text
assert "Cross-Project Move Not Supported" in error_message
assert "test-project-b" in error_message
assert "switch_project" in error_message
@@ -554,6 +554,50 @@ async def test_move_note_cross_project_detection(mcp_server, app):
assert "write_note" in error_message
@pytest.mark.asyncio
async def test_move_note_potential_cross_project_guidance(mcp_server, app):
"""Test guidance for potentially cross-project moves with project-like keywords."""
async with Client(mcp_server) as client:
# Create another test project
await client.call_tool(
"create_memory_project",
{
"project_name": "workspace-docs",
"project_path": "/tmp/workspace-docs",
"set_default": False,
},
)
# Create a note in the default project
await client.call_tool(
"write_note",
{
"title": "Potential Cross Project Note",
"folder": "source",
"content": "# Potential Cross Project Note\n\nThis might be moved cross-project.",
"tags": "test,potential-cross-project",
},
)
# Try to move to a path that contains project-like keywords but not exact project names
move_result = await client.call_tool(
"move_note",
{
"identifier": "Potential Cross Project Note",
"destination_path": "project-archive/moved-note.md",
},
)
# Should provide guidance for potential cross-project moves
assert len(move_result) == 1
error_message = move_result[0].text
assert "Check Project Context" in error_message
assert "workspace-docs" in error_message # Should mention other available projects
assert "list_memory_projects" in error_message
assert "switch_project" 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."""
@@ -580,8 +624,8 @@ async def test_move_note_normal_moves_still_work(mcp_server, app):
)
# Should work normally
assert len(move_result.content) == 1
move_text = move_result.content[0].text
assert len(move_result) == 1
move_text = move_result[0].text
assert "✅ Note moved successfully" in move_text
assert "Normal Move Note" in move_text
assert "destination/normal-moved.md" in move_text
@@ -594,5 +638,5 @@ async def test_move_note_normal_moves_still_work(mcp_server, app):
},
)
content = read_result.content[0].text
content = read_result[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.content) == 1
list_text = list_result.content[0].text
assert len(list_result) == 1
list_text = list_result[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.content) == 1
current_text = current_result.content[0].text
assert len(current_result) == 1
current_text = current_result[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.content) == 1
current_text = current_result.content[0].text
assert len(current_result) == 1
current_text = current_result[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.content) == 1
switch_text = switch_result.content[0].text
assert len(switch_result) == 1
switch_text = switch_result[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.content) == 1
switch_text = switch_result.content[0].text
assert len(switch_result) == 1
switch_text = switch_result[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.content) == 1
current_text = current_result.content[0].text
assert len(current_result) == 1
current_text = current_result[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.content) == 1
default_text = default_result.content[0].text
assert len(default_result) == 1
default_text = default_result[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.content[0].text
assert "test-project" in current_result[0].text
# 2. List all projects
list_result = await client.call_tool("list_memory_projects", {})
assert "Available projects:" in list_result.content[0].text
assert "test-project" in list_result.content[0].text
assert "Available projects:" in list_result[0].text
assert "test-project" in list_result[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.content[0].text
assert "✓ Switched to test-project project" in switch_result[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.content[0].text
assert "Current project: test-project" in current_result2[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.content[0].text
assert "Project: test-project" in list_result[0].text
# get_current_project
current_result = await client.call_tool("get_current_project", {})
assert "Project: test-project" in current_result.content[0].text
assert "Project: test-project" in current_result[0].text
# switch_project
switch_result = await client.call_tool("switch_project", {"project_name": "test-project"})
assert "Project: test-project" in switch_result.content[0].text
assert "Project: test-project" in switch_result[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.content[0].text
# assert "Project: test-project" in default_result[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.content[0].text
initial_text = initial_result[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.content[0].text
updated_text = updated_result[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.content) == 1
create_text = create_result.content[0].text
assert len(create_result) == 1
create_text = create_result[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.content[0].text
list_text = list_result[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.content) == 1
create_text = create_result.content[0].text
assert len(create_result) == 1
create_text = create_result[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.content[0].text
current_text = current_result[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.content[0].text
assert "to-be-deleted" in list_result[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.content) == 1
delete_text = delete_result.content[0].text
assert len(delete_result) == 1
delete_text = delete_result[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.content[0].text
assert "to-be-deleted" not in list_result_after[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.content[0].text
assert project_name in create_result.content[0].text
assert "" in create_result[0].text
assert project_name in create_result[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.content[0].text
assert f"✓ Switched to {project_name} project" in switch_result[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.content[0].text
current_text = current_result[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.content[0].text
assert f"{project_name}" in delete_result.content[0].text
assert "removed successfully" in delete_result.content[0].text
assert "" in delete_result[0].text
assert f"{project_name}" in delete_result[0].text
assert "removed successfully" in delete_result[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.content[0].text
assert project_name not in list_result[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.content[0].text
assert special_name in create_result.content[0].text
assert "" in create_result[0].text
assert special_name in create_result[0].text
# Verify it appears in list
list_result = await client.call_tool("list_memory_projects", {})
assert special_name in list_result.content[0].text
assert special_name in list_result[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.content[0].text
assert special_name in delete_result.content[0].text
assert "" in delete_result[0].text
assert special_name in delete_result[0].text
# Verify it's gone
list_result_after = await client.call_tool("list_memory_projects", {})
assert special_name not in list_result_after.content[0].text
assert special_name not in list_result_after[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.content[0].text
assert project_name in create_result.content[0].text
assert "" in create_result[0].text
assert project_name in create_result[0].text
# Verify project was created with canonical name
list_result = await client.call_tool("list_memory_projects", {})
assert project_name in list_result.content[0].text
assert project_name in list_result[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.content[0].text
assert project_name in switch_result.content[0].text # Canonical name should appear
assert "✓ Switched to" in switch_result[0].text
assert project_name in switch_result[0].text # Canonical name should appear
# Project summary may be unavailable in test environment
assert (
"Project Summary:" in switch_result.content[0].text
or "Project summary unavailable" in switch_result.content[0].text
"Project Summary:" in switch_result[0].text
or "Project summary unavailable" in switch_result[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.content[0].text
current_text = current_result[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.content[0].text
assert "" in create_result[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.content[0].text
assert project_name in switch_result.content[0].text # Should show canonical name
assert "✓ Switched to" in switch_result[0].text
assert project_name in switch_result[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.content) == 1
assert "Case Test Note" in write_result.content[0].text
assert len(write_result) == 1
assert "Case Test Note" in write_result[0].text
# 2. Verify get_current_project shows stats correctly
current_result = await client.call_tool("get_current_project", {})
current_text = current_result.content[0].text
current_text = current_result[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.content) == 1
assert "Case Test Note" in search_result.content[0].text
assert len(search_result) == 1
assert "Case Test Note" in search_result[0].text
# 4. Test read_note works
read_result = await client.call_tool(
"read_note",
{"identifier": "Case Test Note"},
)
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()
assert len(read_result) == 1
assert "Case Test Note" in read_result[0].text
assert "case insensitive" in read_result[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.content[0].text
assert "Available projects:" in switch_result.content[0].text
assert "test-project" in switch_result.content[0].text
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
@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.content[0].text
list_text = list_result[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.content[0].text
assert project_name in switch_result.content[0].text # Original case preserved
assert "✓ Switched to" in switch_result[0].text
assert project_name in switch_result[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.content[0].text
assert f"Current project: {project_name}" in current_result[0].text
# Clean up - switch back and delete test projects
await client.call_tool("switch_project", {"project_name": "test-project"})
@@ -888,17 +888,14 @@ 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.content[0].text
assert f"Current project: {project_name}" in result[0].text
elif op_name == "list_memory_projects":
assert project_name in result.content[0].text
assert (
"(current)" in result.content[0].text
or "current" in result.content[0].text.lower()
)
assert project_name in result[0].text
assert "(current)" in result[0].text or "current" in result[0].text.lower()
# All operations should include project metadata with canonical name
# FIXME
# assert f"Project: {project_name}" in result.content[0].text
# assert f"Project: {project_name}" in result[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.content) == 1
assert "Current project: test-project" in initial_result.content[0].text
assert len(initial_result) == 1
assert "Current project: test-project" in initial_result[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.content) == 1
assert "" in create_result.content[0].text
assert "minerva" in create_result.content[0].text
assert len(create_result) == 1
assert "" in create_result[0].text
assert "minerva" in create_result[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.content) == 1
assert "" in set_default_result.content[0].text
assert "minerva" in set_default_result.content[0].text
assert len(set_default_result) == 1
assert "" in set_default_result[0].text
assert "minerva" in set_default_result[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.content) == 1
assert len(updated_result) == 1
# The fix should ensure these are consistent now:
updated_text = updated_result.content[0].text
updated_text = updated_result[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.content) == 1
assert "Test Consistency Note" in write_result.content[0].text
assert len(write_result) == 1
assert "Test Consistency Note" in write_result[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.content) == 1
assert "Test Consistency Note" in read_result.content[0].text
assert "project state sync working" in read_result.content[0].text.lower()
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()
# Step 8: Test that edit operations work (this was failing in the original issue)
edit_result = await client.call_tool(
@@ -87,18 +87,15 @@ 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.content) == 1
assert (
"added" in edit_result.content[0].text.lower()
and "lines" in edit_result.content[0].text.lower()
)
assert len(edit_result) == 1
assert "added" in edit_result[0].text.lower() and "lines" in edit_result[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.content) == 1
final_content = final_read_result.content[0].text
assert len(final_read_result) == 1
final_content = final_read_result[0].text
assert "Edit operation successful" in final_content
# Clean up - switch back to test-project
@@ -127,11 +124,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.content[0].text
assert "" in set_result[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.content[0].text
assert f"Current project: {project_name}" in current_result[0].text
# Verify config is also updated
assert config_manager.default_project == project_name
@@ -147,7 +144,7 @@ async def test_multiple_project_switches_maintain_consistency(mcp_server, app, c
"tags": "test",
},
)
assert note_title in write_result.content[0].text
assert note_title in write_result[0].text
# Clean up - switch back to test-project
await client.call_tool("set_default_project", {"project_name": "test-project"})
@@ -162,8 +159,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.content) == 1
result_text = switch_result.content[0].text
assert len(switch_result) == 1
result_text = switch_result[0].text
# Should show an error message
assert "Error:" in result_text
@@ -173,4 +170,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.content[0].text
assert "Current project: test-project" in current_result[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.content) == 1
assert mcp_result.content[0].type == "text"
return json.loads(mcp_result.content[0].text)
assert len(mcp_result) == 1
assert mcp_result[0].type == "text"
return json.loads(mcp_result[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.content) == 1
assert read_result.content[0].type == "text"
content = read_result.content[0].text
assert len(read_result) == 1
assert read_result[0].type == "text"
content = read_result[0].text
assert f"# {title}" in content
assert f"Content for {title}" in content
+6 -6
View File
@@ -24,9 +24,9 @@ async def test_read_note_after_write(mcp_server, app):
},
)
assert len(write_result.content) == 1
assert write_result.content[0].type == "text"
assert "Test Note.md" in write_result.content[0].text
assert len(write_result) == 1
assert write_result[0].type == "text"
assert "Test Note.md" in write_result[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.content) == 1
assert read_result.content[0].type == "text"
result_text = read_result.content[0].text
assert len(read_result) == 1
assert read_result[0].type == "text"
result_text = read_result[0].text
# Should contain the note content and metadata
assert "# Test Note" in result_text
+15 -15
View File
@@ -53,11 +53,11 @@ async def test_search_basic_text_search(mcp_server, app):
},
)
assert len(search_result.content) == 1
assert search_result.content[0].type == "text"
assert len(search_result) == 1
assert search_result[0].type == "text"
# Parse the response (it should be a SearchResponse)
result_text = search_result.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[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.content[0].text
result_text = search_result[0].text
assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}"
+25 -25
View File
@@ -26,9 +26,9 @@ async def test_write_note_basic_creation(mcp_server, app):
},
)
assert len(result.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content[0].text
assert "# Created note" in result1[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.content) == 1
assert result2.content[0].type == "text"
response_text = result2.content[0].text
assert len(result2) == 1
assert result2[0].type == "text"
response_text = result2[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[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.content) == 1
assert result.content[0].type == "text"
response_text = result.content[0].text
assert len(result) == 1
assert result[0].type == "text"
response_text = result[0].text
assert "# Created note" in response_text
assert "file_path: test/Frontmatter Note.md" in response_text
-36
View File
@@ -1,36 +0,0 @@
"""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
+37 -233
View File
@@ -1,5 +1,8 @@
"""Tests for the project router API endpoints."""
import json
from unittest.mock import patch
import pytest
@@ -73,6 +76,40 @@ 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."""
@@ -159,236 +196,3 @@ 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
+352
View File
@@ -0,0 +1,352 @@
"""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
+1 -3
View File
@@ -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 get_project_config
from basic_memory.config import config
# Set up CLI runner
runner = CliRunner()
@@ -176,8 +176,6 @@ 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 get_project_config
from basic_memory.config import config
# Set up CLI runner
runner = CliRunner()
@@ -86,7 +86,6 @@ 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"
@@ -135,7 +134,6 @@ 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
+1 -4
View File
@@ -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 get_project_config
from basic_memory.config import config
# Set up CLI runner
runner = CliRunner()
@@ -59,7 +59,6 @@ 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
@@ -84,7 +83,6 @@ 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"
@@ -132,7 +130,6 @@ 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
-40
View File
@@ -113,43 +113,3 @@ 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
-38
View File
@@ -141,41 +141,3 @@ 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
+20 -11
View File
@@ -11,7 +11,7 @@ from basic_memory.cli.commands.sync import (
group_issues_by_directory,
ValidationIssue,
)
from basic_memory.config import get_project_config
from basic_memory.config import config
from basic_memory.sync.sync_service import SyncReport
# Set up CLI runner
@@ -74,7 +74,6 @@ 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
@@ -100,14 +99,19 @@ def test_sync_command():
mock_run_sync.return_value = None
# Mock config values that the sync command prints
result = runner.invoke(app, ["sync", "--verbose"])
assert result.exit_code == 0
with patch("basic_memory.cli.commands.sync.config") as mock_config:
mock_config.project = "test-project"
mock_config.home = "/test/path"
# Verify output contains project info
assert "Syncing project: test-project" in result.stdout
result = runner.invoke(app, ["sync", "--verbose"])
assert result.exit_code == 0
# Verify the function was called with verbose=True
mock_run_sync.assert_called_once_with(verbose=True)
# 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)
def test_sync_command_error():
@@ -119,6 +123,11 @@ def test_sync_command_error():
# Mock an error
mock_run_sync.side_effect = Exception("Sync failed")
result = runner.invoke(app, ["sync", "--verbose"])
assert result.exit_code == 1
assert "Error during sync: Sync failed" in result.stderr
# 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
+42 -3
View File
@@ -51,8 +51,6 @@ 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
@@ -68,6 +66,8 @@ 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,8 +82,37 @@ 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
@@ -97,7 +126,7 @@ def project_session(test_project: Project):
@pytest.fixture(scope="function", autouse=True)
def project_config(test_project):
def project_config(test_project, monkeypatch):
"""Create test project configuration."""
project_config = ProjectConfig(
@@ -105,6 +134,8 @@ def project_config(test_project):
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
@@ -119,6 +150,14 @@ 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)
+2
View File
@@ -13,6 +13,8 @@ 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:
+313
View File
@@ -0,0 +1,313 @@
"""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"]
+144
View File
@@ -0,0 +1,144 @@
"""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, clear=True):
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")
+5 -5
View File
@@ -364,7 +364,7 @@ async def test_edit_note_find_replace_empty_find_text(client):
@pytest.mark.asyncio
async def test_edit_note_preserves_permalink_when_frontmatter_missing(client):
"""Test that editing a note preserves the permalink when frontmatter doesn't contain one.
This is a regression test for issue #170 where edit_note would fail with a validation error
because the permalink was being set to None when the markdown file didn't have a permalink
in its frontmatter.
@@ -382,15 +382,15 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client):
operation="append",
content="\nFirst edit.",
)
assert isinstance(first_result, str)
assert "permalink: test/test-note" in first_result
# Perform another edit - this should preserve the permalink even if the
# Perform another edit - this should preserve the permalink even if the
# file doesn't have a permalink in its frontmatter
second_result = await edit_note.fn(
identifier="test/test-note",
operation="append",
operation="append",
content="\nSecond edit.",
)
-264
View File
@@ -461,270 +461,6 @@ class TestMoveNoteErrorFormatting:
assert "Check disk space" in result
class TestMoveNoteSecurityValidation:
"""Test move note security validation features."""
@pytest.mark.asyncio
async def test_move_note_blocks_path_traversal_unix(self, client):
"""Test that Unix-style path traversal attacks are blocked."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test various Unix-style path traversal patterns
attack_paths = [
"../secrets.txt",
"../../etc/passwd",
"../../../root/.ssh/id_rsa",
"notes/../../../etc/shadow",
"folder/../../outside/file.md",
"../../../../etc/hosts",
]
for attack_path in attack_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=attack_path,
)
assert isinstance(result, str)
assert "# Move Failed - Security Validation Error" in result
assert "paths must stay within project boundaries" in result
assert attack_path in result
assert "Try again with a safe path" in result
@pytest.mark.asyncio
async def test_move_note_blocks_path_traversal_windows(self, client):
"""Test that Windows-style path traversal attacks are blocked."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test various Windows-style path traversal patterns
attack_paths = [
"..\\secrets.txt",
"..\\..\\Windows\\System32\\config\\SAM",
"notes\\..\\..\\..\\Windows\\System32",
"\\\\server\\share\\file.txt",
"..\\..\\Users\\user\\.env",
"\\\\..\\..\\Windows",
]
for attack_path in attack_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=attack_path,
)
assert isinstance(result, str)
assert "# Move Failed - Security Validation Error" in result
assert "paths must stay within project boundaries" in result
assert attack_path in result
@pytest.mark.asyncio
async def test_move_note_blocks_absolute_paths(self, client):
"""Test that absolute paths are blocked."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test various absolute path patterns
attack_paths = [
"/etc/passwd",
"/home/user/.env",
"/var/log/auth.log",
"/root/.ssh/id_rsa",
"C:\\Windows\\System32\\config\\SAM",
"C:\\Users\\user\\.env",
"D:\\secrets\\config.json",
"/tmp/malicious.txt",
]
for attack_path in attack_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=attack_path,
)
assert isinstance(result, str)
assert "# Move Failed - Security Validation Error" in result
assert "paths must stay within project boundaries" in result
assert attack_path in result
@pytest.mark.asyncio
async def test_move_note_blocks_home_directory_access(self, client):
"""Test that home directory access patterns are blocked."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test various home directory access patterns
attack_paths = [
"~/secrets.txt",
"~/.env",
"~/.ssh/id_rsa",
"~/Documents/passwords.txt",
"~\\AppData\\secrets",
"~\\Desktop\\config.ini",
]
for attack_path in attack_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=attack_path,
)
assert isinstance(result, str)
assert "# Move Failed - Security Validation Error" in result
assert "paths must stay within project boundaries" in result
assert attack_path in result
@pytest.mark.asyncio
async def test_move_note_blocks_mixed_attack_patterns(self, client):
"""Test that mixed legitimate/attack patterns are blocked."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test mixed patterns that start legitimate but contain attacks
attack_paths = [
"notes/../../../etc/passwd",
"docs/../../.env",
"legitimate/path/../../.ssh/id_rsa",
"project/folder/../../../Windows/System32",
"valid/folder/../../home/user/.bashrc",
]
for attack_path in attack_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=attack_path,
)
assert isinstance(result, str)
assert "# Move Failed - Security Validation Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_move_note_allows_safe_paths(self, client):
"""Test that legitimate paths are still allowed."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test various safe path patterns
safe_paths = [
"notes/meeting.md",
"docs/readme.txt",
"projects/2025/planning.md",
"archive/old-notes/backup.md",
"deep/nested/directory/structure/file.txt",
"folder/subfolder/document.md",
]
for safe_path in safe_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=safe_path,
)
# Should succeed or fail for legitimate reasons (not security)
assert isinstance(result, str)
# Should NOT contain security error message
assert "Security Validation Error" not in result
# If it fails, it should be for other reasons like "already exists" or API errors
if "Move Failed" in result:
assert "paths must stay within project boundaries" not in result
@pytest.mark.asyncio
async def test_move_note_security_logging(self, client, caplog):
"""Test that security violations are properly logged."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Attempt path traversal attack
result = await move_note.fn(
identifier="source/test-note",
destination_path="../../../etc/passwd",
)
assert "# Move Failed - Security Validation Error" in result
# Check that security violation was logged
# Note: This test may need adjustment based on the actual logging setup
# The security validation should generate a warning log entry
@pytest.mark.asyncio
async def test_move_note_empty_path_security(self, client):
"""Test that empty destination path is handled securely."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test empty destination path (should be allowed as it resolves to project root)
result = await move_note.fn(
identifier="source/test-note",
destination_path="",
)
assert isinstance(result, str)
# Empty path should not trigger security error (it's handled by pathlib validation)
# But may fail for other API-related reasons
@pytest.mark.asyncio
async def test_move_note_current_directory_references_security(self, client):
"""Test that current directory references are handled securely."""
# Create initial note
await write_note.fn(
title="Test Note",
folder="source",
content="# Test Note\nTest content for security testing.",
)
# Test current directory references (should be safe)
safe_paths = [
"./notes/file.md",
"folder/./file.md",
"./folder/subfolder/file.md",
]
for safe_path in safe_paths:
result = await move_note.fn(
identifier="source/test-note",
destination_path=safe_path,
)
assert isinstance(result, str)
# Should NOT contain security error message
assert "Security Validation Error" not in result
class TestMoveNoteErrorHandling:
"""Test move note exception handling."""
-459
View File
@@ -1,459 +0,0 @@
"""Tests for the read_content MCP tool security validation."""
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from basic_memory.mcp.tools.read_content import read_content
from basic_memory.mcp.tools.write_note import write_note
class TestReadContentSecurityValidation:
"""Test read_content security validation features."""
@pytest.mark.asyncio
async def test_read_content_blocks_path_traversal_unix(self, client):
"""Test that Unix-style path traversal attacks are blocked."""
# Test various Unix-style path traversal patterns
attack_paths = [
"../secrets.txt",
"../../etc/passwd",
"../../../root/.ssh/id_rsa",
"notes/../../../etc/shadow",
"folder/../../outside/file.md",
"../../../../etc/hosts",
"../../../home/user/.env",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@pytest.mark.asyncio
async def test_read_content_blocks_path_traversal_windows(self, client):
"""Test that Windows-style path traversal attacks are blocked."""
# Test various Windows-style path traversal patterns
attack_paths = [
"..\\secrets.txt",
"..\\..\\Windows\\System32\\config\\SAM",
"notes\\..\\..\\..\\Windows\\System32",
"\\\\server\\share\\file.txt",
"..\\..\\Users\\user\\.env",
"\\\\..\\..\\Windows",
"..\\..\\..\\Boot.ini",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@pytest.mark.asyncio
async def test_read_content_blocks_absolute_paths(self, client):
"""Test that absolute paths are blocked."""
# Test various absolute path patterns
attack_paths = [
"/etc/passwd",
"/home/user/.env",
"/var/log/auth.log",
"/root/.ssh/id_rsa",
"C:\\Windows\\System32\\config\\SAM",
"C:\\Users\\user\\.env",
"D:\\secrets\\config.json",
"/tmp/malicious.txt",
"/usr/local/bin/evil",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@pytest.mark.asyncio
async def test_read_content_blocks_home_directory_access(self, client):
"""Test that home directory access patterns are blocked."""
# Test various home directory access patterns
attack_paths = [
"~/secrets.txt",
"~/.env",
"~/.ssh/id_rsa",
"~/Documents/passwords.txt",
"~\\AppData\\secrets",
"~\\Desktop\\config.ini",
"~/.bashrc",
"~/Library/Preferences/secret.plist",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@pytest.mark.asyncio
async def test_read_content_blocks_mixed_attack_patterns(self, client):
"""Test that mixed legitimate/attack patterns are blocked."""
# Test mixed patterns that start legitimate but contain attacks
attack_paths = [
"notes/../../../etc/passwd",
"docs/../../.env",
"legitimate/path/../../.ssh/id_rsa",
"project/folder/../../../Windows/System32",
"valid/folder/../../home/user/.bashrc",
"assets/../../../tmp/evil.exe",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@pytest.mark.asyncio
async def test_read_content_allows_safe_paths_with_mocked_api(self, client):
"""Test that legitimate paths are still allowed with mocked API responses."""
# Test various safe path patterns with mocked API responses
safe_paths = [
"notes/meeting.md",
"docs/readme.txt",
"projects/2025/planning.md",
"archive/old-notes/backup.md",
"assets/diagram.png",
"folder/subfolder/document.md",
]
for safe_path in safe_paths:
# Mock the API call to simulate a successful response
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
mock_response.text = f"# Content for {safe_path}\nThis is test content."
mock_call_get.return_value = mock_response
result = await read_content.fn(path=safe_path)
# Should succeed (not a security error)
assert isinstance(result, dict)
assert result[
"type"
] != "error" or "paths must stay within project boundaries" not in result.get(
"error", ""
)
@pytest.mark.asyncio
async def test_read_content_memory_url_processing(self, client):
"""Test that memory URLs are processed correctly for security validation."""
# Test memory URLs with attacks
attack_paths = [
"memory://../../etc/passwd",
"memory://../../../root/.ssh/id_rsa",
"memory://~/.env",
"memory:///etc/passwd",
]
for attack_path in attack_paths:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@pytest.mark.asyncio
async def test_read_content_security_logging(self, client, caplog):
"""Test that security violations are properly logged."""
# Attempt path traversal attack
result = await read_content.fn(path="../../../etc/passwd")
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
# Check that security violation was logged
# Note: This test may need adjustment based on the actual logging setup
# The security validation should generate a warning log entry
@pytest.mark.asyncio
async def test_read_content_empty_path_security(self, client):
"""Test that empty path is handled securely."""
# Mock the API call since empty path should be allowed (resolves to project root)
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "50"}
mock_response.text = "# Root content"
mock_call_get.return_value = mock_response
result = await read_content.fn(path="")
assert isinstance(result, dict)
# Empty path should not trigger security error (it's handled as project root)
assert result[
"type"
] != "error" or "paths must stay within project boundaries" not in result.get(
"error", ""
)
@pytest.mark.asyncio
async def test_read_content_current_directory_references_security(self, client):
"""Test that current directory references are handled securely."""
# Test current directory references (should be safe)
safe_paths = [
"./notes/file.md",
"folder/./file.md",
"./folder/subfolder/file.md",
]
for safe_path in safe_paths:
# Mock the API call for these safe paths
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
mock_response.text = f"# Content for {safe_path}"
mock_call_get.return_value = mock_response
result = await read_content.fn(path=safe_path)
assert isinstance(result, dict)
# Should NOT contain security error message
assert result[
"type"
] != "error" or "paths must stay within project boundaries" not in result.get(
"error", ""
)
class TestReadContentFunctionality:
"""Test read_content basic functionality with security validation in place."""
@pytest.mark.asyncio
async def test_read_content_text_file_success(self, client):
"""Test reading a text file works correctly with security validation."""
# First create a file to read
await write_note.fn(
title="Test Document",
folder="docs",
content="# Test Document\nThis is test content for reading.",
)
# Mock the API call to simulate reading the file
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
mock_response.text = "# Test Document\nThis is test content for reading."
mock_call_get.return_value = mock_response
result = await read_content.fn(path="docs/test-document.md")
assert isinstance(result, dict)
assert result["type"] == "text"
assert "Test Document" in result["text"]
assert result["content_type"] == "text/markdown"
assert result["encoding"] == "utf-8"
@pytest.mark.asyncio
async def test_read_content_image_file_handling(self, client):
"""Test reading an image file with security validation."""
# Mock the API call to simulate reading an image
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
# Create a simple fake image data
fake_image_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
mock_response = MagicMock()
mock_response.headers = {
"content-type": "image/png",
"content-length": str(len(fake_image_data)),
}
mock_response.content = fake_image_data
mock_call_get.return_value = mock_response
# Mock PIL Image processing
with patch("basic_memory.mcp.tools.read_content.PILImage") as mock_pil:
mock_img = MagicMock()
mock_img.width = 100
mock_img.height = 100
mock_img.mode = "RGB"
mock_img.getbands.return_value = ["R", "G", "B"]
mock_pil.open.return_value = mock_img
with patch("basic_memory.mcp.tools.read_content.optimize_image") as mock_optimize:
mock_optimize.return_value = b"optimized_image_data"
result = await read_content.fn(path="assets/safe-image.png")
assert isinstance(result, dict)
assert result["type"] == "image"
assert "source" in result
assert result["source"]["type"] == "base64"
assert result["source"]["media_type"] == "image/jpeg"
@pytest.mark.asyncio
async def test_read_content_with_project_parameter(self, client):
"""Test reading content with explicit project parameter."""
# Mock the API call and project configuration
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
with patch(
"basic_memory.mcp.tools.read_content.get_active_project"
) as mock_get_project:
# Mock project configuration
mock_project = MagicMock()
mock_project.project_url = "http://test"
mock_project.home = Path("/test/project")
mock_get_project.return_value = mock_project
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/plain", "content-length": "50"}
mock_response.text = "Project-specific content"
mock_call_get.return_value = mock_response
result = await read_content.fn(
path="notes/project-file.txt", project="specific-project"
)
assert isinstance(result, dict)
assert result["type"] == "text"
assert "Project-specific content" in result["text"]
@pytest.mark.asyncio
async def test_read_content_nonexistent_file_handling(self, client):
"""Test handling of nonexistent files (after security validation)."""
# Mock API call to return 404
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
mock_call_get.side_effect = Exception("File not found")
# This should pass security validation but fail on API call
try:
result = await read_content.fn(path="docs/nonexistent-file.md")
# If no exception is raised, check the result format
assert isinstance(result, dict)
except Exception as e:
# Exception due to API failure is acceptable for this test
assert "File not found" in str(e)
@pytest.mark.asyncio
async def test_read_content_binary_file_handling(self, client):
"""Test reading binary files with security validation."""
# Mock the API call to simulate reading a binary file
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
binary_data = b"Binary file content with special bytes: \x00\x01\x02\x03"
mock_response = MagicMock()
mock_response.headers = {
"content-type": "application/octet-stream",
"content-length": str(len(binary_data)),
}
mock_response.content = binary_data
mock_call_get.return_value = mock_response
result = await read_content.fn(path="files/safe-binary.bin")
assert isinstance(result, dict)
assert result["type"] == "document"
assert "source" in result
assert result["source"]["type"] == "base64"
assert result["source"]["media_type"] == "application/octet-stream"
class TestReadContentEdgeCases:
"""Test edge cases for read_content security validation."""
@pytest.mark.asyncio
async def test_read_content_unicode_path_attacks(self, client):
"""Test that Unicode-based path traversal attempts are blocked."""
# Test Unicode path traversal attempts
unicode_attacks = [
"notes/文档/../../../etc/passwd", # Chinese characters
"docs/café/../../.env", # Accented characters
"files/αβγ/../../../secret.txt", # Greek characters
]
for attack_path in unicode_attacks:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@pytest.mark.asyncio
async def test_read_content_url_encoded_attacks(self, client):
"""Test that URL-encoded path traversal attempts are handled safely."""
# Note: The current implementation may not handle URL encoding,
# but this tests the behavior with URL-encoded patterns
encoded_attacks = [
"notes%2f..%2f..%2f..%2fetc%2fpasswd",
"docs%2f%2e%2e%2f%2e%2e%2f.env",
]
for attack_path in encoded_attacks:
try:
result = await read_content.fn(path=attack_path)
# These may or may not be blocked depending on URL decoding,
# but should not cause security issues
assert isinstance(result, dict)
# If not blocked by security validation, may fail at API level
# which is also acceptable
except Exception:
# Exception due to API failure or other issues is acceptable
# as long as no actual traversal occurs
pass
@pytest.mark.asyncio
async def test_read_content_null_byte_injection(self, client):
"""Test that null byte injection attempts are blocked."""
# Test null byte injection patterns
null_byte_attacks = [
"notes/file.txt\x00../../etc/passwd",
"docs/document.md\x00../../../.env",
]
for attack_path in null_byte_attacks:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
# Should be blocked by security validation or cause an error
if result["type"] == "error":
# Either blocked by security validation or failed due to invalid characters
pass # This is acceptable
@pytest.mark.asyncio
async def test_read_content_very_long_attack_path(self, client):
"""Test handling of very long attack paths."""
# Create a very long path traversal attack
long_attack = "../" * 1000 + "etc/passwd"
result = await read_content.fn(path=long_attack)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@pytest.mark.asyncio
async def test_read_content_case_variations_attacks(self, client):
"""Test that case variations don't bypass security."""
# Test case variations (though case sensitivity depends on filesystem)
case_attacks = [
"../ETC/passwd",
"../Etc/PASSWD",
"..\\WINDOWS\\system32",
"~/.SSH/id_rsa",
]
for attack_path in case_attacks:
result = await read_content.fn(path=attack_path)
assert isinstance(result, dict)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
-323
View File
@@ -294,326 +294,3 @@ async def test_read_note_complete_fallback(mock_call_get, mock_search):
assert "Recent Activity" in result
assert "Create New Note" in result
assert "write_note(" in result
class TestReadNoteSecurityValidation:
"""Test read_note security validation features."""
@pytest.mark.asyncio
async def test_read_note_blocks_path_traversal_unix(self, app):
"""Test that Unix-style path traversal attacks are blocked in identifier parameter."""
# Test various Unix-style path traversal patterns
attack_identifiers = [
"../secrets.txt",
"../../etc/passwd",
"../../../root/.ssh/id_rsa",
"notes/../../../etc/shadow",
"folder/../../outside/file.md",
"../../../../etc/hosts",
"../../../home/user/.env",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_identifier in result
@pytest.mark.asyncio
async def test_read_note_blocks_path_traversal_windows(self, app):
"""Test that Windows-style path traversal attacks are blocked in identifier parameter."""
# Test various Windows-style path traversal patterns
attack_identifiers = [
"..\\secrets.txt",
"..\\..\\Windows\\System32\\config\\SAM",
"notes\\..\\..\\..\\Windows\\System32",
"\\\\server\\share\\file.txt",
"..\\..\\Users\\user\\.env",
"\\\\..\\..\\Windows",
"..\\..\\..\\Boot.ini",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_identifier in result
@pytest.mark.asyncio
async def test_read_note_blocks_absolute_paths(self, app):
"""Test that absolute paths are blocked in identifier parameter."""
# Test various absolute path patterns
attack_identifiers = [
"/etc/passwd",
"/home/user/.env",
"/var/log/auth.log",
"/root/.ssh/id_rsa",
"C:\\Windows\\System32\\config\\SAM",
"C:\\Users\\user\\.env",
"D:\\secrets\\config.json",
"/tmp/malicious.txt",
"/usr/local/bin/evil",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_identifier in result
@pytest.mark.asyncio
async def test_read_note_blocks_home_directory_access(self, app):
"""Test that home directory access patterns are blocked in identifier parameter."""
# Test various home directory access patterns
attack_identifiers = [
"~/secrets.txt",
"~/.env",
"~/.ssh/id_rsa",
"~/Documents/passwords.txt",
"~\\AppData\\secrets",
"~\\Desktop\\config.ini",
"~/.bashrc",
"~/Library/Preferences/secret.plist",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_identifier in result
@pytest.mark.asyncio
async def test_read_note_blocks_memory_url_attacks(self, app):
"""Test that memory URLs with path traversal are blocked."""
# Test memory URLs with attacks embedded
attack_identifiers = [
"memory://../../etc/passwd",
"memory://../../../root/.ssh/id_rsa",
"memory://~/.env",
"memory:///etc/passwd",
"memory://notes/../../../etc/shadow",
"memory://..\\..\\Windows\\System32",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_read_note_blocks_mixed_attack_patterns(self, app):
"""Test that mixed legitimate/attack patterns are blocked in identifier parameter."""
# Test mixed patterns that start legitimate but contain attacks
attack_identifiers = [
"notes/../../../etc/passwd",
"docs/../../.env",
"legitimate/path/../../.ssh/id_rsa",
"project/folder/../../../Windows/System32",
"valid/folder/../../home/user/.bashrc",
"assets/../../../tmp/evil.exe",
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_read_note_allows_safe_identifiers(self, app):
"""Test that legitimate identifiers are still allowed."""
# Test various safe identifier patterns
safe_identifiers = [
"notes/meeting",
"docs/readme",
"projects/2025/planning",
"archive/old-notes/backup",
"folder/subfolder/document",
"research/ml/algorithms",
"meeting-notes",
"test/simple-note",
]
for safe_identifier in safe_identifiers:
result = await read_note.fn(identifier=safe_identifier)
assert isinstance(result, str)
# Should not contain security error message
assert (
"# Error" not in result or "paths must stay within project boundaries" not in result
)
# Should either succeed or fail for legitimate reasons (not found, etc.)
# but not due to security validation
@pytest.mark.asyncio
async def test_read_note_allows_legitimate_titles(self, app):
"""Test that legitimate note titles work normally."""
# Create a test note first
await write_note.fn(
title="Security Test Note",
folder="security-tests",
content="# Security Test Note\nThis is a legitimate note for security testing.",
)
# Test reading by title (should work)
result = await read_note.fn("Security Test Note")
assert isinstance(result, str)
# Should not be a security error
assert "# Error" not in result or "paths must stay within project boundaries" not in result
# Should either return the note content or search results
@pytest.mark.asyncio
async def test_read_note_empty_identifier_security(self, app):
"""Test that empty identifier is handled securely."""
# Empty identifier should be allowed (may return search results or error, but not security error)
result = await read_note.fn(identifier="")
assert isinstance(result, str)
# Empty identifier should not trigger security error
assert "# Error" not in result or "paths must stay within project boundaries" not in result
@pytest.mark.asyncio
async def test_read_note_security_with_all_parameters(self, app):
"""Test security validation works with all read_note parameters."""
# Test that security validation is applied even when all other parameters are provided
result = await read_note.fn(
identifier="../../../etc/malicious",
page=1,
page_size=5,
project=None, # Use default project
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert "../../../etc/malicious" in result
@pytest.mark.asyncio
async def test_read_note_security_logging(self, app, caplog):
"""Test that security violations are properly logged."""
# Attempt path traversal attack
result = await read_note.fn(identifier="../../../etc/passwd")
assert "# Error" in result
assert "paths must stay within project boundaries" in result
# Check that security violation was logged
# Note: This test may need adjustment based on the actual logging setup
# The security validation should generate a warning log entry
@pytest.mark.asyncio
async def test_read_note_preserves_functionality_with_security(self, app):
"""Test that security validation doesn't break normal note reading functionality."""
# Create a note with complex content to ensure security validation doesn't interfere
await write_note.fn(
title="Full Feature Security Test Note",
folder="security-tests",
content=dedent("""
# Full Feature Security Test Note
This note tests that security validation doesn't break normal functionality.
## Observations
- [security] Path validation working correctly #security
- [feature] All features still functional #test
## Relations
- relates_to [[Security Implementation]]
- depends_on [[Path Validation]]
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
entity_type="guide",
)
# Test reading by permalink
result = await read_note.fn("security-tests/full-feature-security-test-note")
# Should succeed normally (not a security error)
assert isinstance(result, str)
assert "# Error" not in result or "paths must stay within project boundaries" not in result
# Should either return content or search results, but not security error
class TestReadNoteSecurityEdgeCases:
"""Test edge cases for read_note security validation."""
@pytest.mark.asyncio
async def test_read_note_unicode_identifier_attacks(self, app):
"""Test that Unicode-based path traversal attempts are blocked."""
# Test Unicode path traversal attempts
unicode_attack_identifiers = [
"notes/文档/../../../etc/passwd", # Chinese characters
"docs/café/../../.env", # Accented characters
"files/αβγ/../../../secret.txt", # Greek characters
]
for attack_identifier in unicode_attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_read_note_very_long_attack_identifier(self, app):
"""Test handling of very long attack identifiers."""
# Create a very long path traversal attack
long_attack_identifier = "../" * 1000 + "etc/malicious"
result = await read_note.fn(identifier=long_attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_read_note_case_variations_attacks(self, app):
"""Test that case variations don't bypass security."""
# Test case variations (though case sensitivity depends on filesystem)
case_attack_identifiers = [
"../ETC/passwd",
"../Etc/PASSWD",
"..\\WINDOWS\\system32",
"~/.SSH/id_rsa",
]
for attack_identifier in case_attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_read_note_whitespace_in_attack_identifiers(self, app):
"""Test that whitespace doesn't help bypass security."""
# Test attack identifiers with various whitespace
whitespace_attack_identifiers = [
" ../../../etc/passwd ",
"\t../../../secrets\t",
" ..\\..\\Windows ",
"notes/ ../../ malicious",
]
for attack_identifier in whitespace_attack_identifiers:
result = await read_note.fn(identifier=attack_identifier)
assert isinstance(result, str)
# The attack should still be blocked even with whitespace
if ".." in attack_identifier.strip() or "~" in attack_identifier.strip():
assert "# Error" in result
assert "paths must stay within project boundaries" in result
+1 -1
View File
@@ -51,7 +51,7 @@ async def test_search_title(client):
# Verify results - handle both success and error cases
if isinstance(response, str):
# If search failed and returned error message, test should fail with informative message
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
else:
# Success case - verify SearchResponse
-394
View File
@@ -652,397 +652,3 @@ async def test_write_note_respects_frontmatter_entity_type(app):
assert "# Guide Content" in content
assert "- guide" in content
assert "- documentation" in content
class TestWriteNoteSecurityValidation:
"""Test write_note security validation features."""
@pytest.mark.asyncio
async def test_write_note_blocks_path_traversal_unix(self, app):
"""Test that Unix-style path traversal attacks are blocked in folder parameter."""
# Test various Unix-style path traversal patterns
attack_folders = [
"../",
"../../",
"../../../",
"../secrets",
"../../etc",
"../../../etc/passwd_folder",
"notes/../../../etc",
"folder/../../outside",
"../../../../malicious",
]
for attack_folder in attack_folders:
result = await write_note.fn(
title="Test Note",
folder=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_folder in result
@pytest.mark.asyncio
async def test_write_note_blocks_path_traversal_windows(self, app):
"""Test that Windows-style path traversal attacks are blocked in folder parameter."""
# Test various Windows-style path traversal patterns
attack_folders = [
"..\\",
"..\\..\\",
"..\\..\\..\\",
"..\\secrets",
"..\\..\\Windows",
"..\\..\\..\\Windows\\System32",
"notes\\..\\..\\..\\Windows",
"\\\\server\\share",
"\\\\..\\..\\Windows",
]
for attack_folder in attack_folders:
result = await write_note.fn(
title="Test Note",
folder=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_folder in result
@pytest.mark.asyncio
async def test_write_note_blocks_absolute_paths(self, app):
"""Test that absolute paths are blocked in folder parameter."""
# Test various absolute path patterns
attack_folders = [
"/etc",
"/home/user",
"/var/log",
"/root",
"C:\\Windows",
"C:\\Users\\user",
"D:\\secrets",
"/tmp/malicious",
"/usr/local/evil",
]
for attack_folder in attack_folders:
result = await write_note.fn(
title="Test Note",
folder=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_folder in result
@pytest.mark.asyncio
async def test_write_note_blocks_home_directory_access(self, app):
"""Test that home directory access patterns are blocked in folder parameter."""
# Test various home directory access patterns
attack_folders = [
"~",
"~/",
"~/secrets",
"~/.ssh",
"~/Documents",
"~\\AppData",
"~\\Desktop",
"~/.env_folder",
]
for attack_folder in attack_folders:
result = await write_note.fn(
title="Test Note",
folder=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert attack_folder in result
@pytest.mark.asyncio
async def test_write_note_blocks_mixed_attack_patterns(self, app):
"""Test that mixed legitimate/attack patterns are blocked in folder parameter."""
# Test mixed patterns that start legitimate but contain attacks
attack_folders = [
"notes/../../../etc",
"docs/../../.env_folder",
"legitimate/path/../../.ssh",
"project/folder/../../../Windows",
"valid/folder/../../home/user",
"assets/../../../tmp/evil",
]
for attack_folder in attack_folders:
result = await write_note.fn(
title="Test Note",
folder=attack_folder,
content="# Test Content\nThis should be blocked by security validation.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_write_note_allows_safe_folder_paths(self, app):
"""Test that legitimate folder paths are still allowed."""
# Test various safe folder patterns
safe_folders = [
"notes",
"docs",
"projects/2025",
"archive/old-notes",
"deep/nested/directory/structure",
"folder/subfolder",
"research/ml",
"meeting-notes",
]
for safe_folder in safe_folders:
result = await write_note.fn(
title=f"Test Note in {safe_folder.replace('/', '-')}",
folder=safe_folder,
content="# Test Content\nThis should work normally with security validation.",
tags=["test", "security"],
)
# Should succeed (not a security error)
assert isinstance(result, str)
assert "# Error" not in result
assert "paths must stay within project boundaries" not in result
# Should be normal successful creation/update
assert ("# Created note" in result) or ("# Updated note" in result)
assert safe_folder in result # Should show in file_path
@pytest.mark.asyncio
async def test_write_note_empty_folder_security(self, app):
"""Test that empty folder parameter is handled securely."""
# Empty folder should be allowed (creates in root)
result = await write_note.fn(
title="Root Note",
folder="",
content="# Root Note\nThis note should be created in the project root.",
)
assert isinstance(result, str)
# Empty folder should not trigger security error
assert "# Error" not in result
assert "paths must stay within project boundaries" not in result
# Should succeed normally
assert ("# Created note" in result) or ("# Updated note" in result)
@pytest.mark.asyncio
async def test_write_note_none_folder_security(self, app):
"""Test that default folder behavior works securely when folder is omitted."""
# The write_note function requires folder parameter, but we can test with empty string
# which effectively creates in project root
result = await write_note.fn(
title="Root Folder Note",
folder="", # Empty string instead of None since folder is required
content="# Root Folder Note\nThis note should be created in the project root.",
)
assert isinstance(result, str)
# Empty folder should not trigger security error
assert "# Error" not in result
assert "paths must stay within project boundaries" not in result
# Should succeed normally
assert ("# Created note" in result) or ("# Updated note" in result)
@pytest.mark.asyncio
async def test_write_note_current_directory_references_security(self, app):
"""Test that current directory references are handled securely."""
# Test current directory references (should be safe)
safe_folders = [
"./notes",
"folder/./subfolder",
"./folder/subfolder",
]
for safe_folder in safe_folders:
result = await write_note.fn(
title=f"Current Dir Test {safe_folder.replace('/', '-').replace('.', 'dot')}",
folder=safe_folder,
content="# Current Directory Test\nThis should work with current directory references.",
)
assert isinstance(result, str)
# Should NOT contain security error message
assert "# Error" not in result
assert "paths must stay within project boundaries" not in result
# Should succeed normally
assert ("# Created note" in result) or ("# Updated note" in result)
@pytest.mark.asyncio
async def test_write_note_security_with_all_parameters(self, app):
"""Test security validation works with all write_note parameters."""
# Test that security validation is applied even when all other parameters are provided
result = await write_note.fn(
title="Security Test with All Params",
folder="../../../etc/malicious",
content="# Malicious Content\nThis should be blocked by security validation.",
tags=["malicious", "test"],
entity_type="guide",
project=None, # Use default project
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
assert "../../../etc/malicious" in result
@pytest.mark.asyncio
async def test_write_note_security_logging(self, app, caplog):
"""Test that security violations are properly logged."""
# Attempt path traversal attack
result = await write_note.fn(
title="Security Logging Test",
folder="../../../etc/passwd_folder",
content="# Test Content\nThis should trigger security logging.",
)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
# Check that security violation was logged
# Note: This test may need adjustment based on the actual logging setup
# The security validation should generate a warning log entry
@pytest.mark.asyncio
async def test_write_note_preserves_functionality_with_security(self, app):
"""Test that security validation doesn't break normal note creation functionality."""
# Create a note with all features to ensure security validation doesn't interfere
result = await write_note.fn(
title="Full Feature Security Test",
folder="security-tests",
content=dedent("""
# Full Feature Security Test
This note tests that security validation doesn't break normal functionality.
## Observations
- [security] Path validation working correctly #security
- [feature] All features still functional #test
## Relations
- relates_to [[Security Implementation]]
- depends_on [[Path Validation]]
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
entity_type="guide",
)
# Should succeed normally
assert isinstance(result, str)
assert "# Error" not in result
assert "paths must stay within project boundaries" not in result
assert "# Created note" in result
assert "file_path: security-tests/Full Feature Security Test.md" in result
assert "permalink: security-tests/full-feature-security-test" in result
# Should process observations and relations
assert "## Observations" in result
assert "## Relations" in result
assert "## Tags" in result
# Should show proper counts
assert "security: 1" in result
assert "feature: 1" in result
class TestWriteNoteSecurityEdgeCases:
"""Test edge cases for write_note security validation."""
@pytest.mark.asyncio
async def test_write_note_unicode_folder_attacks(self, app):
"""Test that Unicode-based path traversal attempts are blocked."""
# Test Unicode path traversal attempts
unicode_attack_folders = [
"notes/文档/../../../etc", # Chinese characters
"docs/café/../../secrets", # Accented characters
"files/αβγ/../../../malicious", # Greek characters
]
for attack_folder in unicode_attack_folders:
result = await write_note.fn(
title="Unicode Attack Test",
folder=attack_folder,
content="# Unicode Attack\nThis should be blocked.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_write_note_very_long_attack_folder(self, app):
"""Test handling of very long attack folder paths."""
# Create a very long path traversal attack
long_attack_folder = "../" * 1000 + "etc/malicious"
result = await write_note.fn(
title="Long Attack Test",
folder=long_attack_folder,
content="# Long Attack\nThis should be blocked.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_write_note_case_variations_attacks(self, app):
"""Test that case variations don't bypass security."""
# Test case variations (though case sensitivity depends on filesystem)
case_attack_folders = [
"../ETC",
"../Etc/SECRETS",
"..\\WINDOWS",
"~/SECRETS",
]
for attack_folder in case_attack_folders:
result = await write_note.fn(
title="Case Variation Attack Test",
folder=attack_folder,
content="# Case Attack\nThis should be blocked.",
)
assert isinstance(result, str)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@pytest.mark.asyncio
async def test_write_note_whitespace_in_attack_folders(self, app):
"""Test that whitespace doesn't help bypass security."""
# Test attack folders with various whitespace
whitespace_attack_folders = [
" ../../../etc ",
"\t../../../secrets\t",
" ..\\..\\Windows ",
"notes/ ../../ malicious",
]
for attack_folder in whitespace_attack_folders:
result = await write_note.fn(
title="Whitespace Attack Test",
folder=attack_folder,
content="# Whitespace Attack\nThis should be blocked.",
)
assert isinstance(result, str)
# The attack should still be blocked even with whitespace
if ".." in attack_folder.strip() or "~" in attack_folder.strip():
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@@ -267,31 +267,3 @@ async def test_delete_nonexistent_project(project_repository: ProjectRepository)
"""Test deleting a project that doesn't exist."""
result = await project_repository.delete(999) # Non-existent ID
assert result is False
@pytest.mark.asyncio
async def test_update_path(project_repository: ProjectRepository, sample_project: Project):
"""Test updating a project's path."""
new_path = "/new/project/path"
# Update the project path
updated_project = await project_repository.update_path(sample_project.id, new_path)
# Verify returned object
assert updated_project is not None
assert updated_project.id == sample_project.id
assert updated_project.path == new_path
assert updated_project.name == sample_project.name # Other fields unchanged
# Verify in database
found = await project_repository.find_by_id(sample_project.id)
assert found is not None
assert found.path == new_path
assert found.name == sample_project.name
@pytest.mark.asyncio
async def test_update_path_nonexistent_project(project_repository: ProjectRepository):
"""Test updating path for a project that doesn't exist."""
result = await project_repository.update_path(999, "/some/path") # Non-existent ID
assert result is None
+3 -3
View File
@@ -129,9 +129,9 @@ def test_entity_out_from_attributes():
def test_entity_response_with_none_permalink():
"""Test EntityResponse can handle None permalink (fixes issue #170).
This test ensures that EntityResponse properly validates when the permalink
field is None, which can occur when markdown files don't have explicit
field is None, which can occur when markdown files don't have explicit
permalinks in their frontmatter during edit operations.
"""
# Simulate database model attributes with None permalink
@@ -146,7 +146,7 @@ def test_entity_response_with_none_permalink():
"created_at": "2023-01-01T00:00:00",
"updated_at": "2023-01-01T00:00:00",
}
# This should not raise a ValidationError
entity = EntityResponse.model_validate(db_data)
assert entity.permalink is None
+176
View File
@@ -7,8 +7,11 @@ import pytest
from basic_memory.services.initialization import (
ensure_initialization,
initialize_app,
initialize_database,
reconcile_projects_with_config,
migrate_legacy_projects,
migrate_legacy_project_data,
initialize_file_sync,
)
@@ -31,6 +34,46 @@ async def test_initialize_database_error(mock_get_or_create_db, app_config):
mock_get_or_create_db.assert_called_once_with(app_config.database_path)
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.reconcile_projects_with_config")
@patch("basic_memory.services.migration_service.migration_manager")
@patch("basic_memory.services.initialization.initialize_database")
async def test_initialize_app(
mock_initialize_database,
mock_migration_manager,
mock_reconcile_projects,
app_config,
):
"""Test app initialization."""
mock_migration_manager.start_background_migration = AsyncMock()
result = await initialize_app(app_config)
mock_initialize_database.assert_called_once_with(app_config)
mock_reconcile_projects.assert_called_once_with(app_config)
mock_migration_manager.start_background_migration.assert_called_once_with(app_config)
assert result == mock_migration_manager
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.initialize_database")
@patch("basic_memory.services.initialization.reconcile_projects_with_config")
@patch("basic_memory.services.migration_service.migration_manager")
async def test_initialize_app_sync_disabled(
mock_migration_manager, mock_reconcile_projects, mock_initialize_database, app_config
):
"""Test app initialization with sync disabled."""
app_config.sync_changes = False
mock_migration_manager.start_background_migration = AsyncMock()
result = await initialize_app(app_config)
mock_initialize_database.assert_called_once_with(app_config)
mock_reconcile_projects.assert_called_once_with(app_config)
mock_migration_manager.start_background_migration.assert_called_once_with(app_config)
assert result == mock_migration_manager
@patch("basic_memory.services.initialization.asyncio.run")
def test_ensure_initialization(mock_run, project_config):
"""Test synchronous initialization wrapper."""
@@ -123,6 +166,139 @@ async def test_reconcile_projects_with_error_handling(mock_get_db, app_config):
)
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.db.get_or_create_db")
async def test_migrate_legacy_projects_no_legacy_dirs(mock_get_db, app_config):
"""Test migration when no legacy dirs exist."""
# Setup mocks
mock_session_maker = AsyncMock()
mock_get_db.return_value = (None, mock_session_maker)
mock_repository = AsyncMock()
with (
patch("basic_memory.services.initialization.Path") as mock_path,
patch("basic_memory.services.initialization.ProjectRepository") as mock_repo_class,
patch("basic_memory.services.initialization.migrate_legacy_project_data") as mock_migrate,
):
# Create a mock for the Path instance
mock_legacy_dir = MagicMock()
mock_legacy_dir.exists.return_value = False
mock_path.return_value.__truediv__.return_value = mock_legacy_dir
mock_repo_class.return_value = mock_repository
# Set up app_config projects as a dictionary
app_config.projects = {"test_project": "/path/to/project"}
# Run the function
await migrate_legacy_projects(app_config)
# Assertions - should not call get_by_name or migrate_legacy_project_data
mock_repository.get_by_name.assert_not_called()
mock_migrate.assert_not_called()
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.migrate_legacy_project_data")
@patch("basic_memory.services.initialization.db.get_or_create_db")
async def test_migrate_legacy_projects_with_legacy_dirs(
mock_get_db, mock_migrate_legacy, app_config, tmp_path
):
"""Test migration with legacy dirs."""
# Setup mocks
mock_session_maker = AsyncMock()
mock_get_db.return_value = (None, mock_session_maker)
mock_repository = AsyncMock()
mock_project = MagicMock()
mock_project.name = "test_project"
mock_project.id = 1 # Add numeric ID
# Create a temporary legacy dir
legacy_dir = tmp_path / ".basic-memory"
legacy_dir.mkdir(exist_ok=True)
# Mock the repository
with patch("basic_memory.services.initialization.ProjectRepository") as mock_repo_class:
mock_repo_class.return_value = mock_repository
mock_repository.get_by_name.return_value = mock_project
# Set up app_config projects as a dictionary
app_config.projects = {"test_project": str(tmp_path)}
# Run the function
with patch("basic_memory.services.initialization.Path", lambda x: Path(x)):
await migrate_legacy_projects(app_config)
# Assertions
mock_repository.get_by_name.assert_called_once_with("test_project")
mock_migrate_legacy.assert_called_once_with(mock_project, legacy_dir)
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.shutil.rmtree")
async def test_migrate_legacy_project_data_success(mock_rmtree, tmp_path):
"""Test successful migration of legacy project data."""
# Setup mocks
mock_project = MagicMock()
mock_project.name = "test_project"
mock_project.path = str(tmp_path)
mock_project.id = 1 # Add numeric ID
mock_sync_service = AsyncMock()
mock_sync_service.sync = AsyncMock()
# Create a legacy dir
legacy_dir = tmp_path / ".basic-memory"
# Run the function
with patch(
"basic_memory.cli.commands.sync.get_sync_service", AsyncMock(return_value=mock_sync_service)
):
result = await migrate_legacy_project_data(mock_project, legacy_dir)
# Assertions
mock_sync_service.sync.assert_called_once_with(
Path(mock_project.path), project_name=mock_project.name
)
mock_rmtree.assert_called_once_with(legacy_dir)
assert result is True
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.shutil.rmtree")
async def test_migrate_legacy_project_data_rmtree_error(mock_rmtree, tmp_path):
"""Test migration of legacy project data with rmtree error."""
# Setup mocks
mock_project = MagicMock()
mock_project.name = "test_project"
mock_project.path = str(tmp_path)
mock_project.id = 1 # Add numeric ID
mock_sync_service = AsyncMock()
mock_sync_service.sync = AsyncMock()
# Make rmtree raise an exception
mock_rmtree.side_effect = Exception("Test error")
# Create a legacy dir
legacy_dir = tmp_path / ".basic-memory"
# Run the function
with patch(
"basic_memory.cli.commands.sync.get_sync_service", AsyncMock(return_value=mock_sync_service)
):
result = await migrate_legacy_project_data(mock_project, legacy_dir)
# Assertions
mock_sync_service.sync.assert_called_once_with(
Path(mock_project.path), project_name=mock_project.name
)
mock_rmtree.assert_called_once_with(legacy_dir)
assert result is False
@pytest.mark.asyncio
@patch("basic_memory.services.initialization.db.get_or_create_db")
@patch("basic_memory.cli.commands.sync.get_sync_service")
+15 -127
View File
@@ -442,9 +442,10 @@ async def test_synchronize_projects_calls_ensure_single_default(
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
config_manager = ConfigManager()
try:
# Add project to config only (simulating unsynchronized state)
from basic_memory.config import config_manager
config_manager.add_project(test_project_name, test_project_path)
# Verify it's in config but not in database
@@ -483,14 +484,18 @@ async def test_synchronize_projects_normalizes_project_names(
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
config_manager = ConfigManager()
# Import config manager outside try block
from basic_memory.config import config_manager
try:
# Manually add the unnormalized project name to config
# Save the original config state for potential debugging
# original_projects = config_manager.projects.copy()
# Add project with unnormalized name directly to config
config = config_manager.load_config()
config.projects[unnormalized_name] = test_project_path
config_manager.save_config(config)
config_manager.config.projects[unnormalized_name] = test_project_path
config_manager.save_config(config_manager.config)
# Verify the unnormalized name is in config
assert unnormalized_name in project_service.projects
@@ -535,124 +540,6 @@ async def test_synchronize_projects_normalizes_project_names(
await project_service.repository.delete(db_project.id)
@pytest.mark.asyncio
async def test_move_project(project_service: ProjectService, tmp_path):
"""Test moving a project to a new location."""
test_project_name = f"test-move-project-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
# Create old directory
os.makedirs(old_path, exist_ok=True)
try:
# Add project with initial path
await project_service.add_project(test_project_name, old_path)
# Verify initial state
assert test_project_name in project_service.projects
assert project_service.projects[test_project_name] == old_path
project = await project_service.repository.get_by_name(test_project_name)
assert project is not None
assert project.path == old_path
# Move project to new location
await project_service.move_project(test_project_name, new_path)
# Verify config was updated
assert project_service.projects[test_project_name] == new_path
# Verify database was updated
updated_project = await project_service.repository.get_by_name(test_project_name)
assert updated_project is not None
assert updated_project.path == new_path
# Verify new directory was created
assert os.path.exists(new_path)
finally:
# Clean up
if test_project_name in project_service.projects:
await project_service.remove_project(test_project_name)
@pytest.mark.asyncio
async def test_move_project_nonexistent(project_service: ProjectService, tmp_path):
"""Test moving a project that doesn't exist."""
new_path = str(tmp_path / "new-location")
with pytest.raises(ValueError, match="not found in configuration"):
await project_service.move_project("nonexistent-project", new_path)
@pytest.mark.asyncio
async def test_move_project_db_mismatch(project_service: ProjectService, tmp_path):
"""Test moving a project that exists in config but not in database."""
test_project_name = f"test-move-mismatch-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
# Create directories
os.makedirs(old_path, exist_ok=True)
config_manager = project_service.config_manager
try:
# Add project to config only (not to database)
config_manager.add_project(test_project_name, old_path)
# Verify it's in config but not in database
assert test_project_name in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
# Try to move project - should fail and restore config
with pytest.raises(ValueError, match="not found in database"):
await project_service.move_project(test_project_name, new_path)
# Verify config was restored to original path
assert project_service.projects[test_project_name] == old_path
finally:
# Clean up
if test_project_name in project_service.projects:
config_manager.remove_project(test_project_name)
@pytest.mark.asyncio
async def test_move_project_expands_path(project_service: ProjectService, tmp_path):
"""Test that move_project expands ~ and relative paths."""
test_project_name = f"test-move-expand-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
# Create old directory
os.makedirs(old_path, exist_ok=True)
try:
# Add project with initial path
await project_service.add_project(test_project_name, old_path)
# Use a relative path for the move
relative_new_path = "./new-location"
expected_absolute_path = os.path.abspath(relative_new_path)
# Move project using relative path
await project_service.move_project(test_project_name, relative_new_path)
# Verify the path was expanded to absolute
assert project_service.projects[test_project_name] == expected_absolute_path
updated_project = await project_service.repository.get_by_name(test_project_name)
assert updated_project is not None
assert updated_project.path == expected_absolute_path
finally:
# Clean up
if test_project_name in project_service.projects:
await project_service.remove_project(test_project_name)
@pytest.mark.asyncio
async def test_synchronize_projects_handles_case_sensitivity_bug(
project_service: ProjectService, tmp_path
@@ -666,12 +553,13 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
config_manager = ConfigManager()
# Import config manager outside try block
from basic_memory.config import config_manager
try:
# Add project with uppercase name to config (simulating the bug scenario)
config = config_manager.load_config()
config.projects[config_name] = test_project_path
config_manager.save_config(config)
config_manager.config.projects[config_name] = test_project_path
config_manager.save_config(config_manager.config)
# Verify the uppercase name is in config
assert config_name in project_service.projects
-341
View File
@@ -1,341 +0,0 @@
"""Test character-related sync conflicts and permalink generation."""
from pathlib import Path
from textwrap import dedent
import pytest
from sqlalchemy.exc import IntegrityError
from basic_memory.config import ProjectConfig
from basic_memory.repository import EntityRepository
from basic_memory.sync.sync_service import SyncService
from basic_memory.utils import (
generate_permalink,
normalize_file_path_for_comparison,
detect_potential_file_conflicts,
)
async def create_test_file(path: Path, content: str = "test content") -> None:
"""Create a test file with given content."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
class TestUtilityFunctions:
"""Test utility functions for file path normalization and conflict detection."""
def test_normalize_file_path_for_comparison(self):
"""Test file path normalization for conflict detection."""
# Case sensitivity normalization
assert (
normalize_file_path_for_comparison("Finance/Investment.md") == "finance/investment.md"
)
assert (
normalize_file_path_for_comparison("FINANCE/INVESTMENT.MD") == "finance/investment.md"
)
# Path separator normalization
assert (
normalize_file_path_for_comparison("Finance\\Investment.md") == "finance/investment.md"
)
# Multiple slash handling
assert (
normalize_file_path_for_comparison("Finance//Investment.md") == "finance/investment.md"
)
def test_detect_potential_file_conflicts(self):
"""Test the enhanced conflict detection function."""
existing_paths = [
"Finance/Investment.md",
"finance/Investment.md",
"docs/my-feature.md",
"docs/my feature.md",
]
# Case sensitivity conflict
conflicts = detect_potential_file_conflicts("FINANCE/INVESTMENT.md", existing_paths)
assert "Finance/Investment.md" in conflicts
assert "finance/Investment.md" in conflicts
# Permalink conflict (space vs hyphen)
conflicts = detect_potential_file_conflicts("docs/my_feature.md", existing_paths)
assert "docs/my-feature.md" in conflicts
assert "docs/my feature.md" in conflicts
class TestPermalinkGeneration:
"""Test permalink generation with various character scenarios."""
def test_hyphen_handling(self):
"""Test that hyphens in filenames are handled consistently."""
# File with existing hyphens
assert generate_permalink("docs/my-feature.md") == "docs/my-feature"
assert generate_permalink("docs/basic-memory bug.md") == "docs/basic-memory-bug"
# File with spaces that become hyphens
assert generate_permalink("docs/my feature.md") == "docs/my-feature"
# Mixed scenarios
assert generate_permalink("docs/my-old feature.md") == "docs/my-old-feature"
def test_forward_slash_handling(self):
"""Test that forward slashes are handled properly."""
# Normal directory structure
assert generate_permalink("Finance/Investment.md") == "finance/investment"
# Path with spaces in directory names
assert generate_permalink("My Finance/Investment.md") == "my-finance/investment"
def test_case_sensitivity_normalization(self):
"""Test that case differences are normalized consistently."""
# Same logical path with different cases
assert generate_permalink("Finance/Investment.md") == "finance/investment"
assert generate_permalink("finance/Investment.md") == "finance/investment"
assert generate_permalink("FINANCE/INVESTMENT.md") == "finance/investment"
def test_unicode_character_handling(self):
"""Test that international characters are handled properly."""
# Italian characters as mentioned in user feedback
assert (
generate_permalink("Finance/Punti Chiave di Peter Lynch.md")
== "finance/punti-chiave-di-peter-lynch"
)
# Chinese characters (should be preserved)
assert generate_permalink("中文/测试文档.md") == "中文/测试文档"
# Mixed international characters
assert generate_permalink("docs/Café München.md") == "docs/cafe-munchen"
def test_special_punctuation(self):
"""Test handling of special punctuation characters."""
# Apostrophes should be removed
assert generate_permalink("Peter's Guide.md") == "peters-guide"
# Other punctuation should become hyphens
assert generate_permalink("Q&A Session.md") == "q-a-session"
@pytest.mark.asyncio
class TestSyncConflictHandling:
"""Test sync service handling of file path and permalink conflicts."""
async def test_file_path_conflict_detection(
self,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test that file path conflicts are detected during move operations."""
project_dir = project_config.home
# Create two files
content1 = dedent("""
---
type: knowledge
---
# Document One
This is the first document.
""")
content2 = dedent("""
---
type: knowledge
---
# Document Two
This is the second document.
""")
await create_test_file(project_dir / "doc1.md", content1)
await create_test_file(project_dir / "doc2.md", content2)
# Initial sync
await sync_service.sync(project_config.home)
# Verify both entities exist
entities = await entity_repository.find_all()
assert len(entities) == 2
# Now simulate a move where doc1.md tries to move to doc2.md's location
# This should be handled gracefully, not throw an IntegrityError
# First, get the entities
entity1 = await entity_repository.get_by_file_path("doc1.md")
entity2 = await entity_repository.get_by_file_path("doc2.md")
assert entity1 is not None
assert entity2 is not None
# Simulate the conflict scenario
with pytest.raises(Exception) as exc_info:
# This should detect the conflict and handle it gracefully
await sync_service.handle_move("doc1.md", "doc2.md")
# The exception should be a meaningful error, not an IntegrityError
assert not isinstance(exc_info.value, IntegrityError)
async def test_hyphen_filename_conflict(
self,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test conflict when filename with hyphens conflicts with generated permalink."""
project_dir = project_config.home
# Create file with spaces (will generate permalink with hyphens)
content1 = dedent("""
---
type: knowledge
---
# Basic Memory Bug
This file has spaces in the name.
""")
# Create file with hyphens (already has hyphens in filename)
content2 = dedent("""
---
type: knowledge
---
# Basic Memory Bug Report
This file has hyphens in the name.
""")
await create_test_file(project_dir / "basic memory bug.md", content1)
await create_test_file(project_dir / "basic-memory-bug.md", content2)
# Sync should handle this without conflict
await sync_service.sync(project_config.home)
# Verify both entities were created with unique permalinks
entities = await entity_repository.find_all()
assert len(entities) == 2
# Check that permalinks are unique
permalinks = [entity.permalink for entity in entities if entity.permalink]
assert len(set(permalinks)) == len(permalinks), "Permalinks should be unique"
async def test_case_sensitivity_conflict(
self,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test conflict handling when case differences cause issues."""
import platform
project_dir = project_config.home
# Create directory structure that might cause case conflicts
(project_dir / "Finance").mkdir(parents=True, exist_ok=True)
(project_dir / "finance").mkdir(parents=True, exist_ok=True)
content1 = dedent("""
---
type: knowledge
---
# Investment Guide
Upper case directory.
""")
content2 = dedent("""
---
type: knowledge
---
# Investment Tips
Lower case directory.
""")
await create_test_file(project_dir / "Finance" / "investment.md", content1)
await create_test_file(project_dir / "finance" / "investment.md", content2)
# Sync should handle case differences properly
await sync_service.sync(project_config.home)
# Verify entities were created
entities = await entity_repository.find_all()
# On case-insensitive file systems (macOS, Windows), only one entity will be created
# On case-sensitive file systems (Linux), two entities will be created
if platform.system() in ["Darwin", "Windows"]:
# Case-insensitive file systems
assert len(entities) >= 1
# Only one of the paths will exist
file_paths = [entity.file_path for entity in entities]
assert any(path in ["Finance/investment.md", "finance/investment.md"] for path in file_paths)
else:
# Case-sensitive file systems (Linux)
assert len(entities) >= 2
# Check that file paths are preserved correctly
file_paths = [entity.file_path for entity in entities]
assert "Finance/investment.md" in file_paths
assert "finance/investment.md" in file_paths
async def test_move_conflict_resolution(
self,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test that move conflicts are resolved with proper error handling."""
project_dir = project_config.home
# Create three files in a scenario that could cause move conflicts
await create_test_file(project_dir / "file-a.md", "# File A")
await create_test_file(project_dir / "file-b.md", "# File B")
await create_test_file(project_dir / "temp.md", "# Temp File")
# Initial sync
await sync_service.sync(project_config.home)
# Simulate a complex move scenario where files swap locations
# This is the kind of scenario that caused the original bug
# Get the entities
entity_a = await entity_repository.get_by_file_path("file-a.md")
entity_b = await entity_repository.get_by_file_path("file-b.md")
entity_temp = await entity_repository.get_by_file_path("temp.md")
assert all([entity_a, entity_b, entity_temp])
# Try to move file-a to file-b's location (should detect conflict)
try:
await sync_service.handle_move("file-a.md", "file-b.md")
# If this doesn't raise an exception, the conflict was resolved
# Verify the state is consistent
updated_entities = await entity_repository.find_all()
file_paths = [entity.file_path for entity in updated_entities]
# Should not have duplicate file paths
assert len(file_paths) == len(set(file_paths)), "File paths should be unique"
except Exception as e:
# If an exception is raised, it should be a meaningful error
assert "conflict" in str(e).lower() or "already exists" in str(e).lower()
assert not isinstance(e, IntegrityError), "Should not be a raw IntegrityError"
@pytest.mark.asyncio
class TestEnhancedErrorMessages:
"""Test that error messages provide helpful guidance for character conflicts."""
async def test_helpful_error_for_hyphen_conflict(
self,
sync_service: SyncService,
project_config: ProjectConfig,
):
"""Test that hyphen conflicts generate helpful error messages."""
# This test will be implemented after we enhance the error handling
pass
async def test_helpful_error_for_case_conflict(
self,
sync_service: SyncService,
project_config: ProjectConfig,
):
"""Test that case sensitivity conflicts generate helpful error messages."""
# This test will be implemented after we enhance the error handling
pass
+6 -4
View File
@@ -120,7 +120,9 @@ async def test_get_or_create_db_runs_migrations_automatically(
db._session_maker = None
# First call should create engine and run migrations
engine, session_maker = await db.get_or_create_db(app_config.database_path)
engine, session_maker = await db.get_or_create_db(
app_config.database_path, app_config=app_config
)
# Verify we got valid objects
assert engine is not None
@@ -166,7 +168,7 @@ async def test_multiple_get_or_create_db_calls_deduplicated(
db._session_maker = None
# First call should create engine and run migrations
await db.get_or_create_db(app_config.database_path)
await db.get_or_create_db(app_config.database_path, app_config=app_config)
# Verify migrations were called
mock_alembic_command.upgrade.assert_called_once_with(mock_alembic_config, "head")
@@ -177,8 +179,8 @@ async def test_multiple_get_or_create_db_calls_deduplicated(
mock_search_repository.reset_mock()
# Subsequent calls should not run migrations again
await db.get_or_create_db(app_config.database_path)
await db.get_or_create_db(app_config.database_path)
await db.get_or_create_db(app_config.database_path, app_config=app_config)
await db.get_or_create_db(app_config.database_path, app_config=app_config)
# Verify migrations were NOT called again
mock_alembic_command.upgrade.assert_not_called()

Some files were not shown because too many files have changed in this diff Show More