Compare commits

...

6 Commits

Author SHA1 Message Date
claude[bot] e604455332 fix: replace deprecated datetime.utcnow() with timezone-aware alternatives
Replace all datetime.utcnow() calls with datetime.now(timezone.utc) to fix Python 3.13 deprecation warnings:

- Update SQLAlchemy model defaults in project.py with lambda functions
- Fix JWT token generation in auth providers
- Update test fixtures with timezone-aware datetime
- Add timezone imports where needed

Fixes Python 3.13 deprecation warnings that appeared when running commands like 'bm project add'.
Code is now ready for Python 3.15 when datetime.utcnow() will be removed.

Fixes #210

Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
2025-07-03 13:12:48 +00:00
Drew Cain 23ddf1918c chore: update version to 0.14.1 for v0.14.1 release 2025-07-01 22:08:25 -05:00
Drew Cain 2aca19aa05 chore: apply ruff formatting 2025-07-01 22:05:17 -05:00
Drew Cain 827f7cf3e3 fix: constrain fastmcp version to prevent breaking changes (#203)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-01 22:01:59 -05:00
Drew Cain bd4f55158b fix: Problems with MCP #190 (#202)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2025-07-01 10:50:44 -05:00
Drew Cain 5360005122 feat: Add to cursor button (#200)
Signed-off-by: Drew Cain <groksrc@gmail.com>
2025-07-01 09:17:48 -05:00
16 changed files with 58 additions and 51 deletions
+8 -1
View File
@@ -70,6 +70,13 @@ npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. Note: The Smithery installation uses their hosted MCP server, while your data remains stored locally as Markdown files.
### Add to Cursor
Once you have installed Basic Memory revisit this page for the 1-click installer for Cursor:
[![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">
@@ -218,7 +225,7 @@ title: <Entity title>
type: <The type of Entity> (e.g. note)
permalink: <a uri slug>
- <optional metadata> (such as tags)
- <optional metadata> (such as tags)
```
### Observations
+1 -1
View File
@@ -17,7 +17,7 @@ test: test-unit test-int
# Lint and fix code
lint:
ruff check . --fix
uv run ruff check . --fix
# Type check code
type-check:
+2 -2
View File
@@ -30,7 +30,7 @@ dependencies = [
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
"fastmcp>=2.3.4",
"fastmcp>=2.3.4,<2.10.0",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
@@ -123,4 +123,4 @@ omit = [
]
[tool.logfire]
ignore_no_config = true
ignore_no_config = true
+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.0"
__version__ = "0.14.1"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+8 -8
View File
@@ -1,7 +1,7 @@
"""OAuth authentication provider for Basic Memory MCP server."""
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
import jwt
@@ -92,7 +92,7 @@ class BasicMemoryOAuthProvider(
self.authorization_codes[auth_code] = BasicMemoryAuthorizationCode(
code=auth_code,
scopes=params.scopes or [],
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=10)).timestamp(),
client_id=client.client_id,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
@@ -119,7 +119,7 @@ class BasicMemoryOAuthProvider(
if code and code.client_id == client.client_id:
# Check if expired
if datetime.utcnow().timestamp() > code.expires_at:
if datetime.now(timezone.utc).timestamp() > code.expires_at:
del self.authorization_codes[authorization_code]
return None
return code
@@ -135,7 +135,7 @@ class BasicMemoryOAuthProvider(
refresh_token = secrets.token_urlsafe(32)
# Store tokens
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).timestamp()
self.access_tokens[access_token] = BasicMemoryAccessToken(
token=access_token,
@@ -187,7 +187,7 @@ class BasicMemoryOAuthProvider(
new_refresh_token = secrets.token_urlsafe(32)
# Store new tokens
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).timestamp()
self.access_tokens[new_access_token] = BasicMemoryAccessToken(
token=new_access_token,
@@ -220,7 +220,7 @@ class BasicMemoryOAuthProvider(
if access_token:
# Check if expired
if access_token.expires_at and datetime.utcnow().timestamp() > access_token.expires_at:
if access_token.expires_at and datetime.now(timezone.utc).timestamp() > access_token.expires_at:
logger.debug("Token found in memory but expired, removing")
del self.access_tokens[token]
return None
@@ -262,8 +262,8 @@ class BasicMemoryOAuthProvider(
"iss": self.issuer_url,
"sub": client_id,
"aud": "basic-memory",
"exp": datetime.utcnow() + timedelta(hours=1),
"iat": datetime.utcnow(),
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
"iat": datetime.now(timezone.utc),
"scopes": scopes,
}
@@ -3,7 +3,7 @@
import os
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import httpx
@@ -123,7 +123,7 @@ class SupabaseOAuthProvider(
self.pending_auth_codes[state] = SupabaseAuthorizationCode(
code=state,
scopes=params.scopes or [],
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=10)).timestamp(),
client_id=client.client_id,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
@@ -218,7 +218,7 @@ class SupabaseOAuthProvider(
if code and code.client_id == client.client_id:
# Check expiration
if datetime.utcnow().timestamp() > code.expires_at:
if datetime.now(timezone.utc).timestamp() > code.expires_at:
del self.pending_auth_codes[authorization_code]
return None
return code
@@ -453,8 +453,8 @@ class SupabaseOAuthProvider(
"email": email,
"scopes": scopes,
"supabase_token": supabase_access_token[:10] + "...", # Reference only
"exp": datetime.utcnow() + timedelta(hours=1),
"iat": datetime.utcnow(),
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
"iat": datetime.now(timezone.utc),
}
# Use Supabase JWT secret if available
+4 -4
View File
@@ -20,24 +20,24 @@ from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.sync_status import sync_status
from basic_memory.mcp.tools.project_management import (
list_projects,
list_memory_projects,
switch_project,
get_current_project,
set_default_project,
create_project,
create_memory_project,
delete_project,
)
__all__ = [
"build_context",
"canvas",
"create_project",
"create_memory_project",
"delete_note",
"delete_project",
"edit_note",
"get_current_project",
"list_directory",
"list_projects",
"list_memory_projects",
"move_note",
"read_content",
"read_note",
@@ -19,7 +19,7 @@ from basic_memory.utils import generate_permalink
@mcp.tool("list_memory_projects")
async def list_projects(ctx: Context | None = None) -> str:
async def list_memory_projects(ctx: Context | None = None) -> str:
"""List all available projects with their status.
Shows all Basic Memory projects that are available, indicating which one
@@ -29,7 +29,7 @@ async def list_projects(ctx: Context | None = None) -> str:
Formatted list of projects with status indicators
Example:
list_projects()
list_memory_projects()
"""
if ctx: # pragma: no cover
await ctx.info("Listing all available projects")
@@ -144,13 +144,13 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
Your session remains on the previous project.
## Troubleshooting:
1. **Check available projects**: Use `list_projects()` to see valid project names
1. **Check available projects**: Use `list_memory_projects()` to see valid project names
2. **Verify spelling**: Ensure the project name is spelled correctly
3. **Check permissions**: Verify you have access to the requested project
4. **Try again**: The error might be temporary
## Available options:
- See all projects: `list_projects()`
- See all projects: `list_memory_projects()`
- Stay on current project: `get_current_project()`
- Try different project: `switch_project("correct-project-name")`
@@ -231,7 +231,7 @@ async def set_default_project(project_name: str, ctx: Context | None = None) ->
@mcp.tool("create_memory_project")
async def create_project(
async def create_memory_project(
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
) -> str:
"""Create a new Basic Memory project.
@@ -248,8 +248,8 @@ async def create_project(
Confirmation message with project details
Example:
create_project("my-research", "~/Documents/research")
create_project("work-notes", "/home/user/work", set_default=True)
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
if ctx: # pragma: no cover
await ctx.info(f"Creating project: {project_name} at {project_path}")
+4 -4
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()
@@ -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**:
@@ -183,7 +183,7 @@ 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
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
"""Project model for Basic Memory."""
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import (
@@ -52,9 +52,9 @@ class Project(Base):
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
# Define relationships to entities, observations, and relations
+4 -4
View File
@@ -1,7 +1,7 @@
"""Tests for OAuth authentication provider."""
import pytest
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyHttpUrl
@@ -185,7 +185,7 @@ class TestBasicMemoryOAuthProvider:
token=token_str,
client_id=client.client_id,
scopes=["read", "write"],
expires_at=int((datetime.utcnow() + timedelta(hours=1)).timestamp()),
expires_at=int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp()),
)
provider.access_tokens[token_str] = access_token
@@ -226,7 +226,7 @@ class TestBasicMemoryOAuthProvider:
provider.authorization_codes[auth_code] = BasicMemoryAuthorizationCode(
code=auth_code,
scopes=["read"],
expires_at=(datetime.utcnow() - timedelta(minutes=1)).timestamp(),
expires_at=(datetime.now(timezone.utc) - timedelta(minutes=1)).timestamp(),
client_id=client.client_id,
code_challenge="challenge",
redirect_uri=AnyHttpUrl("http://localhost:3000/callback"),
@@ -288,7 +288,7 @@ class TestBasicMemoryOAuthProvider:
token=expired_token_str,
client_id="test-client",
scopes=["read"],
expires_at=int((datetime.utcnow() - timedelta(minutes=1)).timestamp()), # Expired
expires_at=int((datetime.now(timezone.utc) - timedelta(minutes=1)).timestamp()), # Expired
)
provider.access_tokens[expired_token_str] = expired_access_token
+1 -1
View File
@@ -93,7 +93,7 @@ class TestMCPServer:
# Missing SUPABASE_ANON_KEY
}
with patch.dict(os.environ, env_vars):
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()
+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.",
)
+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
+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
Generated
+1 -1
View File
@@ -121,7 +121,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14.1" },
{ name = "dateparser", specifier = ">=1.2.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" },
{ name = "fastmcp", specifier = ">=2.3.4" },
{ name = "fastmcp", specifier = ">=2.3.4,<2.10.0" },
{ name = "greenlet", specifier = ">=3.1.1" },
{ name = "icecream", specifier = ">=2.1.3" },
{ name = "loguru", specifier = ">=0.7.3" },