memento-brain-memory 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memento_brain_memory-0.0.1/.gitignore +35 -0
- memento_brain_memory-0.0.1/PKG-INFO +46 -0
- memento_brain_memory-0.0.1/README.md +23 -0
- memento_brain_memory-0.0.1/mcp_server/__init__.py +1 -0
- memento_brain_memory-0.0.1/mcp_server/__main__.py +49 -0
- memento_brain_memory-0.0.1/mcp_server/db.py +150 -0
- memento_brain_memory-0.0.1/mcp_server/graph.py +162 -0
- memento_brain_memory-0.0.1/mcp_server/remote_client.py +143 -0
- memento_brain_memory-0.0.1/mcp_server/search.py +254 -0
- memento_brain_memory-0.0.1/mcp_server/server.py +415 -0
- memento_brain_memory-0.0.1/pyproject.toml +41 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Node
|
|
10
|
+
node_modules/
|
|
11
|
+
.next/
|
|
12
|
+
|
|
13
|
+
# IDE
|
|
14
|
+
.DS_Store
|
|
15
|
+
.vscode/
|
|
16
|
+
.idea/
|
|
17
|
+
|
|
18
|
+
# Env
|
|
19
|
+
.env
|
|
20
|
+
.env.local
|
|
21
|
+
|
|
22
|
+
# Install script artifacts
|
|
23
|
+
.venv-embedding/
|
|
24
|
+
|
|
25
|
+
# DB
|
|
26
|
+
*.sqlite
|
|
27
|
+
*.db
|
|
28
|
+
|
|
29
|
+
# Logs
|
|
30
|
+
*.log
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
Thumbs.db
|
|
34
|
+
.env
|
|
35
|
+
.claude/
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memento-brain-memory
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: MCP Memory Server — personal AI memory powered by Memento data
|
|
5
|
+
Project-URL: Homepage, https://github.com/ddong8/memento
|
|
6
|
+
Project-URL: Repository, https://github.com/ddong8/memento
|
|
7
|
+
Project-URL: Issues, https://github.com/ddong8/memento/issues
|
|
8
|
+
Author: ddong8
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai,claude,knowledge-graph,mcp,memento,memory
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: asyncpg>=0.30
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: mcp>=1.26
|
|
19
|
+
Requires-Dist: openai>=1.30
|
|
20
|
+
Requires-Dist: pgvector>=0.3
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Memento MCP Server
|
|
25
|
+
|
|
26
|
+
Personal AI memory powered by your Memento data.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install memento-brain-memory
|
|
32
|
+
memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Claude Code Configuration
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"memento-memory": {
|
|
41
|
+
"command": "memento-memory",
|
|
42
|
+
"args": ["--db-url", "postgresql+asyncpg://postgres:postgres@localhost:5433/memento"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Memento MCP Server
|
|
2
|
+
|
|
3
|
+
Personal AI memory powered by your Memento data.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install memento-brain-memory
|
|
9
|
+
memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Claude Code Configuration
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"memento-memory": {
|
|
18
|
+
"command": "memento-memory",
|
|
19
|
+
"args": ["--db-url", "postgresql+asyncpg://postgres:postgres@localhost:5433/memento"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Memento MCP Server — personal AI memory for all tools."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Entry point for the MCP Memory Server.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
# Remote mode (recommended — no DB needed, works anywhere):
|
|
5
|
+
memento-memory --server https://report.ihasy.com --token YOUR_JWT_TOKEN
|
|
6
|
+
|
|
7
|
+
# Direct DB mode (local dev / self-hosted):
|
|
8
|
+
memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
description="Memento MCP Server — personal AI memory for all tools",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--server", help="Memento server URL (e.g. https://report.ihasy.com)")
|
|
23
|
+
parser.add_argument("--token", help="JWT token for authentication")
|
|
24
|
+
parser.add_argument("--db-url", help="PostgreSQL connection URL (for direct DB mode)")
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
server_url = args.server or os.environ.get("MEMENTO_SERVER_URL")
|
|
28
|
+
token = args.token or os.environ.get("MEMENTO_SERVER_TOKEN")
|
|
29
|
+
db_url = args.db_url or os.environ.get("MEMENTO_DATABASE_URL")
|
|
30
|
+
|
|
31
|
+
from .server import mcp, init_server
|
|
32
|
+
|
|
33
|
+
if server_url and token:
|
|
34
|
+
init_server(server_url=server_url, token=token)
|
|
35
|
+
elif db_url:
|
|
36
|
+
init_server(db_url=db_url)
|
|
37
|
+
else:
|
|
38
|
+
print("Error: Either --server/--token or --db-url is required.", file=sys.stderr)
|
|
39
|
+
print("\nRemote mode (recommended):", file=sys.stderr)
|
|
40
|
+
print(" memento-memory --server https://report.ihasy.com --token YOUR_TOKEN", file=sys.stderr)
|
|
41
|
+
print("\nDirect DB mode:", file=sys.stderr)
|
|
42
|
+
print(" memento-memory --db-url postgresql+asyncpg://...", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
mcp.run(transport="stdio")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Database connection for MCP server — connects directly to PostgreSQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import (
|
|
8
|
+
BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer,
|
|
9
|
+
String, Text, UniqueConstraint, func,
|
|
10
|
+
)
|
|
11
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
13
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import date, datetime
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from pgvector.sqlalchemy import Vector
|
|
19
|
+
except ImportError:
|
|
20
|
+
Vector = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Base(DeclarativeBase):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Minimal model definitions (mirror of server models, read-only)
|
|
28
|
+
|
|
29
|
+
class User(Base):
|
|
30
|
+
__tablename__ = "users"
|
|
31
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
32
|
+
email: Mapped[str] = mapped_column(String(320))
|
|
33
|
+
name: Mapped[str | None] = mapped_column(String(255))
|
|
34
|
+
role: Mapped[str] = mapped_column(String(20))
|
|
35
|
+
collector_token: Mapped[str | None] = mapped_column(String(64))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Machine(Base):
|
|
39
|
+
__tablename__ = "machines"
|
|
40
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
41
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
42
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Tool(Base):
|
|
46
|
+
__tablename__ = "tools"
|
|
47
|
+
id: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
48
|
+
display_name: Mapped[str] = mapped_column(String(100))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Project(Base):
|
|
52
|
+
__tablename__ = "projects"
|
|
53
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
54
|
+
slug: Mapped[str] = mapped_column(String(255))
|
|
55
|
+
title: Mapped[str] = mapped_column(String(500))
|
|
56
|
+
tool_id: Mapped[str | None] = mapped_column(ForeignKey("tools.id"))
|
|
57
|
+
source_path: Mapped[str | None] = mapped_column(Text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Document(Base):
|
|
61
|
+
__tablename__ = "documents"
|
|
62
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
63
|
+
tool_id: Mapped[str] = mapped_column(String(50))
|
|
64
|
+
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"))
|
|
65
|
+
machine_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("machines.id"))
|
|
66
|
+
relative_path: Mapped[str] = mapped_column(Text)
|
|
67
|
+
category: Mapped[str] = mapped_column(String(50))
|
|
68
|
+
content_type: Mapped[str] = mapped_column(String(50))
|
|
69
|
+
title: Mapped[str | None] = mapped_column(Text)
|
|
70
|
+
content: Mapped[str | None] = mapped_column(Text)
|
|
71
|
+
content_hash: Mapped[str] = mapped_column(String(64))
|
|
72
|
+
file_size_bytes: Mapped[int] = mapped_column(BigInteger)
|
|
73
|
+
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
|
74
|
+
ai_summary: Mapped[str | None] = mapped_column(Text)
|
|
75
|
+
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ConversationMessage(Base):
|
|
79
|
+
__tablename__ = "conversation_messages"
|
|
80
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
81
|
+
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"))
|
|
82
|
+
line_number: Mapped[int] = mapped_column(Integer)
|
|
83
|
+
message_type: Mapped[str | None] = mapped_column(String(50))
|
|
84
|
+
role: Mapped[str | None] = mapped_column(String(20))
|
|
85
|
+
content: Mapped[str | None] = mapped_column(Text)
|
|
86
|
+
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
|
87
|
+
timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DailySummary(Base):
|
|
91
|
+
__tablename__ = "daily_summaries"
|
|
92
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
93
|
+
summary_date: Mapped[date] = mapped_column(Date)
|
|
94
|
+
tool_id: Mapped[str | None] = mapped_column(String(50))
|
|
95
|
+
title: Mapped[str] = mapped_column(Text)
|
|
96
|
+
summary: Mapped[str] = mapped_column(Text)
|
|
97
|
+
highlights: Mapped[dict | None] = mapped_column(JSONB)
|
|
98
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DocumentEmbedding(Base):
|
|
102
|
+
__tablename__ = "document_embeddings"
|
|
103
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
104
|
+
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"))
|
|
105
|
+
chunk_index: Mapped[int] = mapped_column(Integer)
|
|
106
|
+
chunk_text: Mapped[str] = mapped_column(Text)
|
|
107
|
+
embedding = mapped_column(Vector(1024) if Vector else Text)
|
|
108
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class KnowledgeEntity(Base):
|
|
112
|
+
__tablename__ = "knowledge_entities"
|
|
113
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
114
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
|
|
115
|
+
name: Mapped[str] = mapped_column(Text)
|
|
116
|
+
entity_type: Mapped[str] = mapped_column(String(50))
|
|
117
|
+
summary: Mapped[str | None] = mapped_column(Text)
|
|
118
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
119
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class KnowledgeRelation(Base):
|
|
123
|
+
__tablename__ = "knowledge_relations"
|
|
124
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
125
|
+
source_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
|
|
126
|
+
target_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
|
|
127
|
+
relation_type: Mapped[str] = mapped_column(String(50))
|
|
128
|
+
strength: Mapped[float] = mapped_column(Float)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class KnowledgeObservation(Base):
|
|
132
|
+
__tablename__ = "knowledge_observations"
|
|
133
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
134
|
+
entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
|
|
135
|
+
content: Mapped[str] = mapped_column(Text)
|
|
136
|
+
source_document_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("documents.id"))
|
|
137
|
+
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_db_url() -> str:
|
|
141
|
+
return os.environ.get(
|
|
142
|
+
"MEMENTO_DATABASE_URL",
|
|
143
|
+
"postgresql+asyncpg://postgres:postgres@localhost:5433/memento",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def create_engine_and_session(db_url: str | None = None) -> async_sessionmaker[AsyncSession]:
|
|
148
|
+
url = db_url or get_db_url()
|
|
149
|
+
engine = create_async_engine(url, pool_size=5, max_overflow=10)
|
|
150
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Knowledge graph operations — entity context and observation storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import func, select, or_
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from .db import (
|
|
12
|
+
Document, KnowledgeEntity, KnowledgeObservation, KnowledgeRelation,
|
|
13
|
+
Machine, Project,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_entity_context(
|
|
18
|
+
db: AsyncSession, project_name: str, user_id: uuid.UUID | None = None,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Build comprehensive project context from knowledge graph + documents."""
|
|
21
|
+
parts = []
|
|
22
|
+
|
|
23
|
+
# 1. Find the project
|
|
24
|
+
project_q = select(Project).where(
|
|
25
|
+
or_(
|
|
26
|
+
Project.title.ilike(f"%{project_name}%"),
|
|
27
|
+
Project.slug.ilike(f"%{project_name}%"),
|
|
28
|
+
)
|
|
29
|
+
).limit(1)
|
|
30
|
+
project = (await db.execute(project_q)).scalar_one_or_none()
|
|
31
|
+
|
|
32
|
+
if project:
|
|
33
|
+
parts.append(f"# Project: {project.title}")
|
|
34
|
+
parts.append(f"**Tool**: {project.tool_id}")
|
|
35
|
+
if project.source_path:
|
|
36
|
+
parts.append(f"**Path**: `{project.source_path}`")
|
|
37
|
+
|
|
38
|
+
# Get document stats
|
|
39
|
+
doc_stats = await db.execute(
|
|
40
|
+
select(Document.category, func.count())
|
|
41
|
+
.where(Document.project_id == project.id)
|
|
42
|
+
.group_by(Document.category)
|
|
43
|
+
)
|
|
44
|
+
stats = {r[0]: r[1] for r in doc_stats.all()}
|
|
45
|
+
if stats:
|
|
46
|
+
parts.append("\n## Documents")
|
|
47
|
+
for cat, count in sorted(stats.items()):
|
|
48
|
+
parts.append(f"- {cat}: {count}")
|
|
49
|
+
|
|
50
|
+
# Get recent conversation titles
|
|
51
|
+
recent = await db.execute(
|
|
52
|
+
select(Document.title, Document.synced_at)
|
|
53
|
+
.where(Document.project_id == project.id, Document.category == "conversation")
|
|
54
|
+
.order_by(Document.synced_at.desc())
|
|
55
|
+
.limit(5)
|
|
56
|
+
)
|
|
57
|
+
convos = recent.all()
|
|
58
|
+
if convos:
|
|
59
|
+
parts.append("\n## Recent Conversations")
|
|
60
|
+
for title, synced_at in convos:
|
|
61
|
+
parts.append(f"- {title} ({synced_at.strftime('%Y-%m-%d')})")
|
|
62
|
+
|
|
63
|
+
# Get memory/plan files
|
|
64
|
+
memory_docs = await db.execute(
|
|
65
|
+
select(Document.title, Document.content, Document.category)
|
|
66
|
+
.where(
|
|
67
|
+
Document.project_id == project.id,
|
|
68
|
+
Document.category.in_(["memory", "plan", "identity"]),
|
|
69
|
+
)
|
|
70
|
+
.order_by(Document.synced_at.desc())
|
|
71
|
+
.limit(5)
|
|
72
|
+
)
|
|
73
|
+
for title, content, cat in memory_docs.all():
|
|
74
|
+
parts.append(f"\n## {cat.title()}: {title}")
|
|
75
|
+
parts.append((content or "")[:1000])
|
|
76
|
+
|
|
77
|
+
# 2. Knowledge graph entities matching the project name
|
|
78
|
+
entity_filter = [KnowledgeEntity.name.ilike(f"%{project_name}%")]
|
|
79
|
+
if user_id:
|
|
80
|
+
entity_filter.append(
|
|
81
|
+
or_(KnowledgeEntity.user_id == user_id, KnowledgeEntity.user_id.is_(None))
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
entities = await db.execute(
|
|
85
|
+
select(KnowledgeEntity).where(*entity_filter).limit(10)
|
|
86
|
+
)
|
|
87
|
+
entity_list = entities.scalars().all()
|
|
88
|
+
|
|
89
|
+
if entity_list:
|
|
90
|
+
parts.append("\n## Knowledge Graph")
|
|
91
|
+
for entity in entity_list:
|
|
92
|
+
parts.append(f"\n### {entity.name} ({entity.entity_type})")
|
|
93
|
+
if entity.summary:
|
|
94
|
+
parts.append(entity.summary)
|
|
95
|
+
|
|
96
|
+
# Relations
|
|
97
|
+
rels = await db.execute(
|
|
98
|
+
select(KnowledgeRelation, KnowledgeEntity)
|
|
99
|
+
.join(KnowledgeEntity, KnowledgeRelation.target_id == KnowledgeEntity.id)
|
|
100
|
+
.where(KnowledgeRelation.source_id == entity.id)
|
|
101
|
+
.limit(10)
|
|
102
|
+
)
|
|
103
|
+
for rel, target in rels.all():
|
|
104
|
+
parts.append(f" → {rel.relation_type} → **{target.name}** ({target.entity_type})")
|
|
105
|
+
|
|
106
|
+
# Observations
|
|
107
|
+
obs = await db.execute(
|
|
108
|
+
select(KnowledgeObservation.content, KnowledgeObservation.observed_at)
|
|
109
|
+
.where(KnowledgeObservation.entity_id == entity.id)
|
|
110
|
+
.order_by(KnowledgeObservation.observed_at.desc())
|
|
111
|
+
.limit(5)
|
|
112
|
+
)
|
|
113
|
+
for content, obs_at in obs.all():
|
|
114
|
+
date_str = obs_at.strftime("%Y-%m-%d") if obs_at else ""
|
|
115
|
+
parts.append(f" - [{date_str}] {content}")
|
|
116
|
+
|
|
117
|
+
if not parts:
|
|
118
|
+
return f"No context found for '{project_name}'."
|
|
119
|
+
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def store_observation(
|
|
124
|
+
db: AsyncSession,
|
|
125
|
+
content: str,
|
|
126
|
+
entity_name: str | None = None,
|
|
127
|
+
entity_type: str = "concept",
|
|
128
|
+
user_id: uuid.UUID | None = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Store a new observation, creating the entity if needed."""
|
|
131
|
+
if not entity_name:
|
|
132
|
+
# Auto-extract entity from content (simple heuristic)
|
|
133
|
+
words = content.split()
|
|
134
|
+
entity_name = " ".join(words[:3]) if len(words) > 3 else content[:50]
|
|
135
|
+
|
|
136
|
+
# Find or create entity
|
|
137
|
+
q = select(KnowledgeEntity).where(
|
|
138
|
+
KnowledgeEntity.name == entity_name,
|
|
139
|
+
KnowledgeEntity.entity_type == entity_type,
|
|
140
|
+
)
|
|
141
|
+
if user_id:
|
|
142
|
+
q = q.where(KnowledgeEntity.user_id == user_id)
|
|
143
|
+
|
|
144
|
+
entity = (await db.execute(q)).scalar_one_or_none()
|
|
145
|
+
if not entity:
|
|
146
|
+
entity = KnowledgeEntity(
|
|
147
|
+
user_id=user_id,
|
|
148
|
+
name=entity_name,
|
|
149
|
+
entity_type=entity_type,
|
|
150
|
+
)
|
|
151
|
+
db.add(entity)
|
|
152
|
+
await db.flush()
|
|
153
|
+
|
|
154
|
+
# Add observation
|
|
155
|
+
obs = KnowledgeObservation(
|
|
156
|
+
entity_id=entity.id,
|
|
157
|
+
content=content,
|
|
158
|
+
)
|
|
159
|
+
db.add(obs)
|
|
160
|
+
await db.flush()
|
|
161
|
+
|
|
162
|
+
return f"Stored observation for entity '{entity_name}' ({entity_type}): {content[:100]}"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Remote HTTP client — calls Memento server REST API instead of direct DB access.
|
|
2
|
+
|
|
3
|
+
This lets the MCP server run on any machine without needing PostgreSQL connection.
|
|
4
|
+
Only needs: server URL + user's collector token (JWT or collector_token).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import date
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mcp_memory.remote")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RemoteClient:
|
|
18
|
+
"""HTTP client for Memento server API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, server_url: str, token: str):
|
|
21
|
+
self.base_url = server_url.rstrip("/")
|
|
22
|
+
self.token = token
|
|
23
|
+
self._jwt: str | None = None
|
|
24
|
+
self._jwt_time: float = 0 # When JWT was obtained
|
|
25
|
+
|
|
26
|
+
async def _ensure_jwt(self) -> str:
|
|
27
|
+
"""Get JWT token with auto-renewal (re-exchange after 20 hours)."""
|
|
28
|
+
import time
|
|
29
|
+
# Renew if JWT older than 20 hours (tokens expire at 24h)
|
|
30
|
+
if self._jwt and (time.time() - self._jwt_time) < 72000:
|
|
31
|
+
return self._jwt
|
|
32
|
+
self._jwt = None # Force re-exchange
|
|
33
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
34
|
+
# Try as JWT directly
|
|
35
|
+
resp = await client.get(
|
|
36
|
+
f"{self.base_url}/api/auth/me",
|
|
37
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
38
|
+
)
|
|
39
|
+
if resp.status_code == 200:
|
|
40
|
+
self._jwt = self.token
|
|
41
|
+
self._jwt_time = time.time()
|
|
42
|
+
return self._jwt
|
|
43
|
+
|
|
44
|
+
# Try token-exchange (collector_token → JWT)
|
|
45
|
+
resp = await client.post(
|
|
46
|
+
f"{self.base_url}/api/auth/token-exchange",
|
|
47
|
+
headers={"X-Collector-Token": self.token},
|
|
48
|
+
)
|
|
49
|
+
if resp.status_code == 200:
|
|
50
|
+
data = resp.json()
|
|
51
|
+
self._jwt = data["access_token"]
|
|
52
|
+
self._jwt_time = time.time()
|
|
53
|
+
logger.info("JWT obtained via token-exchange")
|
|
54
|
+
return self._jwt
|
|
55
|
+
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Invalid token. Get a valid token from {self.base_url}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def _get(self, path: str, params: dict | None = None) -> dict | list:
|
|
61
|
+
jwt = await self._ensure_jwt()
|
|
62
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
63
|
+
resp = await client.get(
|
|
64
|
+
f"{self.base_url}{path}",
|
|
65
|
+
params=params,
|
|
66
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
67
|
+
)
|
|
68
|
+
# Auto-retry on 401 (JWT expired)
|
|
69
|
+
if resp.status_code == 401:
|
|
70
|
+
self._jwt = None
|
|
71
|
+
jwt = await self._ensure_jwt()
|
|
72
|
+
resp = await client.get(
|
|
73
|
+
f"{self.base_url}{path}",
|
|
74
|
+
params=params,
|
|
75
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
76
|
+
)
|
|
77
|
+
resp.raise_for_status()
|
|
78
|
+
return resp.json()
|
|
79
|
+
|
|
80
|
+
async def _post(self, path: str, json_data: dict | None = None) -> dict:
|
|
81
|
+
jwt = await self._ensure_jwt()
|
|
82
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
83
|
+
resp = await client.post(
|
|
84
|
+
f"{self.base_url}{path}",
|
|
85
|
+
json=json_data,
|
|
86
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
87
|
+
)
|
|
88
|
+
if resp.status_code == 401:
|
|
89
|
+
self._jwt = None
|
|
90
|
+
jwt = await self._ensure_jwt()
|
|
91
|
+
resp = await client.post(
|
|
92
|
+
f"{self.base_url}{path}",
|
|
93
|
+
json=json_data,
|
|
94
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
return resp.json()
|
|
98
|
+
|
|
99
|
+
# --- Memory search ---
|
|
100
|
+
async def search(self, query: str, limit: int = 5, tool_filter: str | None = None) -> list[dict]:
|
|
101
|
+
params = {"q": query, "limit": limit}
|
|
102
|
+
if tool_filter:
|
|
103
|
+
params["tool"] = tool_filter
|
|
104
|
+
result = await self._get("/api/search", params)
|
|
105
|
+
return result.get("results", []) if isinstance(result, dict) else []
|
|
106
|
+
|
|
107
|
+
# --- Projects ---
|
|
108
|
+
async def list_projects(self) -> list[dict]:
|
|
109
|
+
return await self._get("/api/projects")
|
|
110
|
+
|
|
111
|
+
async def get_project(self, project_id: str) -> dict:
|
|
112
|
+
return await self._get(f"/api/projects/{project_id}")
|
|
113
|
+
|
|
114
|
+
# --- Documents ---
|
|
115
|
+
async def get_document(self, doc_id: str) -> dict:
|
|
116
|
+
return await self._get(f"/api/documents/{doc_id}")
|
|
117
|
+
|
|
118
|
+
# --- Conversations ---
|
|
119
|
+
async def get_conversation_messages(self, doc_id: str, limit: int = 50) -> dict:
|
|
120
|
+
return await self._get(f"/api/conversations/{doc_id}/messages", {"limit": limit})
|
|
121
|
+
|
|
122
|
+
# --- Daily ---
|
|
123
|
+
async def get_daily(self, date_str: str) -> dict:
|
|
124
|
+
from datetime import datetime, timezone, timedelta
|
|
125
|
+
tz = int(datetime.now(timezone.utc).astimezone().utcoffset().total_seconds() // 60)
|
|
126
|
+
return await self._get(f"/api/daily/{date_str}", {"tz_offset": -tz})
|
|
127
|
+
|
|
128
|
+
async def get_daily_dates(self, days: int = 30) -> list[dict]:
|
|
129
|
+
return await self._get("/api/daily", {"days": days})
|
|
130
|
+
|
|
131
|
+
# --- Dashboard ---
|
|
132
|
+
async def get_dashboard(self) -> dict:
|
|
133
|
+
return await self._get("/api/dashboard")
|
|
134
|
+
|
|
135
|
+
# --- Tools ---
|
|
136
|
+
async def get_tools(self) -> list[dict]:
|
|
137
|
+
return await self._get("/api/tools")
|
|
138
|
+
|
|
139
|
+
async def get_tool_files(self, tool_id: str, category: str | None = None) -> list[dict]:
|
|
140
|
+
params = {}
|
|
141
|
+
if category:
|
|
142
|
+
params["category"] = category
|
|
143
|
+
return await self._get(f"/api/tools/{tool_id}/files", params)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Hybrid search engine — semantic (pgvector) + full-text (tsvector) + knowledge graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func, select, text, or_
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from .db import Document, DocumentEmbedding, KnowledgeEntity, KnowledgeObservation, Machine
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mcp_memory.search")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_local_model = None
|
|
18
|
+
_model_lock = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_local_model():
|
|
22
|
+
"""Load BGE-M3 model lazily."""
|
|
23
|
+
global _local_model, _model_lock
|
|
24
|
+
import threading
|
|
25
|
+
if _model_lock is None:
|
|
26
|
+
_model_lock = threading.Lock()
|
|
27
|
+
if _local_model is not None:
|
|
28
|
+
return _local_model
|
|
29
|
+
with _model_lock:
|
|
30
|
+
if _local_model is not None:
|
|
31
|
+
return _local_model
|
|
32
|
+
try:
|
|
33
|
+
from sentence_transformers import SentenceTransformer
|
|
34
|
+
_local_model = SentenceTransformer("BAAI/bge-m3")
|
|
35
|
+
return _local_model
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _get_embedding(query: str) -> list[float] | None:
|
|
41
|
+
"""Generate embedding for a query. Tries local BGE-M3 first, then remote API."""
|
|
42
|
+
import asyncio
|
|
43
|
+
# Try local model
|
|
44
|
+
model = _get_local_model()
|
|
45
|
+
if model is not None:
|
|
46
|
+
try:
|
|
47
|
+
embedding = await asyncio.to_thread(
|
|
48
|
+
lambda: model.encode(query, normalize_embeddings=True).tolist()
|
|
49
|
+
)
|
|
50
|
+
return embedding
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# Fallback: remote API
|
|
55
|
+
import os
|
|
56
|
+
api_key = os.environ.get("MEMENTO_EMBEDDING_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
57
|
+
base_url = os.environ.get("MEMENTO_EMBEDDING_BASE_URL")
|
|
58
|
+
emb_model = os.environ.get("MEMENTO_EMBEDDING_MODEL", "text-embedding-v4")
|
|
59
|
+
if not api_key or not base_url:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
import openai
|
|
63
|
+
client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
64
|
+
response = await client.embeddings.create(input=query, model=emb_model)
|
|
65
|
+
return response.data[0].embedding
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.debug("Embedding generation failed: %s", e)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _semantic_search(
|
|
72
|
+
db: AsyncSession, query: str, limit: int, user_machine_ids: list[uuid.UUID] | None,
|
|
73
|
+
tool_filter: str | None, cutoff: datetime | None,
|
|
74
|
+
) -> list[dict]:
|
|
75
|
+
"""Search via pgvector cosine similarity."""
|
|
76
|
+
embedding = await _get_embedding(query)
|
|
77
|
+
if embedding is None:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
from pgvector.sqlalchemy import Vector
|
|
82
|
+
except ImportError:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
q = (
|
|
86
|
+
select(
|
|
87
|
+
DocumentEmbedding.chunk_text,
|
|
88
|
+
DocumentEmbedding.document_id,
|
|
89
|
+
Document.title,
|
|
90
|
+
Document.tool_id,
|
|
91
|
+
Document.relative_path,
|
|
92
|
+
Document.synced_at,
|
|
93
|
+
DocumentEmbedding.embedding.cosine_distance(embedding).label("distance"),
|
|
94
|
+
)
|
|
95
|
+
.join(Document, DocumentEmbedding.document_id == Document.id)
|
|
96
|
+
.order_by("distance")
|
|
97
|
+
.limit(limit)
|
|
98
|
+
)
|
|
99
|
+
if user_machine_ids is not None:
|
|
100
|
+
q = q.where(Document.machine_id.in_(user_machine_ids))
|
|
101
|
+
if tool_filter:
|
|
102
|
+
q = q.where(Document.tool_id == tool_filter)
|
|
103
|
+
if cutoff:
|
|
104
|
+
q = q.where(Document.synced_at >= cutoff)
|
|
105
|
+
|
|
106
|
+
result = await db.execute(q)
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
"content": row.chunk_text,
|
|
110
|
+
"title": row.title or row.relative_path,
|
|
111
|
+
"tool_id": row.tool_id,
|
|
112
|
+
"relative_path": row.relative_path,
|
|
113
|
+
"date": row.synced_at.strftime("%Y-%m-%d") if row.synced_at else "",
|
|
114
|
+
"score": 1.0 - row.distance, # Convert distance to similarity
|
|
115
|
+
"source": "semantic",
|
|
116
|
+
}
|
|
117
|
+
for row in result.all()
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _fulltext_search(
|
|
122
|
+
db: AsyncSession, query: str, limit: int, user_machine_ids: list[uuid.UUID] | None,
|
|
123
|
+
tool_filter: str | None, cutoff: datetime | None,
|
|
124
|
+
) -> list[dict]:
|
|
125
|
+
"""Search via PostgreSQL LIKE (simple but effective)."""
|
|
126
|
+
pattern = f"%{query}%"
|
|
127
|
+
q = (
|
|
128
|
+
select(Document.title, Document.tool_id, Document.relative_path,
|
|
129
|
+
Document.content, Document.synced_at)
|
|
130
|
+
.where(
|
|
131
|
+
or_(
|
|
132
|
+
Document.content.ilike(pattern),
|
|
133
|
+
Document.title.ilike(pattern),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
.order_by(Document.synced_at.desc())
|
|
137
|
+
.limit(limit)
|
|
138
|
+
)
|
|
139
|
+
if user_machine_ids is not None:
|
|
140
|
+
q = q.where(Document.machine_id.in_(user_machine_ids))
|
|
141
|
+
if tool_filter:
|
|
142
|
+
q = q.where(Document.tool_id == tool_filter)
|
|
143
|
+
if cutoff:
|
|
144
|
+
q = q.where(Document.synced_at >= cutoff)
|
|
145
|
+
|
|
146
|
+
result = await db.execute(q)
|
|
147
|
+
results = []
|
|
148
|
+
for row in result.all():
|
|
149
|
+
# Extract relevant snippet around the match
|
|
150
|
+
content = row.content or ""
|
|
151
|
+
idx = content.lower().find(query.lower())
|
|
152
|
+
if idx >= 0:
|
|
153
|
+
start = max(0, idx - 200)
|
|
154
|
+
end = min(len(content), idx + len(query) + 300)
|
|
155
|
+
snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
|
156
|
+
else:
|
|
157
|
+
snippet = content[:500]
|
|
158
|
+
|
|
159
|
+
results.append({
|
|
160
|
+
"content": snippet,
|
|
161
|
+
"title": row.title or row.relative_path,
|
|
162
|
+
"tool_id": row.tool_id,
|
|
163
|
+
"relative_path": row.relative_path,
|
|
164
|
+
"date": row.synced_at.strftime("%Y-%m-%d") if row.synced_at else "",
|
|
165
|
+
"score": 0.5, # Fixed score for full-text matches
|
|
166
|
+
"source": "fulltext",
|
|
167
|
+
})
|
|
168
|
+
return results
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _graph_search(
|
|
172
|
+
db: AsyncSession, query: str, limit: int, user_id: uuid.UUID | None,
|
|
173
|
+
) -> list[dict]:
|
|
174
|
+
"""Search via knowledge graph entity matching."""
|
|
175
|
+
# Find matching entities
|
|
176
|
+
q = (
|
|
177
|
+
select(KnowledgeEntity.name, KnowledgeEntity.entity_type, KnowledgeEntity.summary)
|
|
178
|
+
.where(KnowledgeEntity.name.ilike(f"%{query}%"))
|
|
179
|
+
.limit(5)
|
|
180
|
+
)
|
|
181
|
+
if user_id:
|
|
182
|
+
q = q.where(or_(KnowledgeEntity.user_id == user_id, KnowledgeEntity.user_id.is_(None)))
|
|
183
|
+
|
|
184
|
+
entities = await db.execute(q)
|
|
185
|
+
entity_rows = entities.all()
|
|
186
|
+
if not entity_rows:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
results = []
|
|
190
|
+
for name, etype, summary in entity_rows:
|
|
191
|
+
# Get recent observations
|
|
192
|
+
obs_q = (
|
|
193
|
+
select(KnowledgeObservation.content, KnowledgeObservation.observed_at)
|
|
194
|
+
.join(KnowledgeEntity, KnowledgeObservation.entity_id == KnowledgeEntity.id)
|
|
195
|
+
.where(KnowledgeEntity.name == name)
|
|
196
|
+
.order_by(KnowledgeObservation.observed_at.desc())
|
|
197
|
+
.limit(3)
|
|
198
|
+
)
|
|
199
|
+
obs_result = await db.execute(obs_q)
|
|
200
|
+
observations = [f"- {r.content}" for r in obs_result.all()]
|
|
201
|
+
|
|
202
|
+
content = f"**{name}** ({etype})\n"
|
|
203
|
+
if summary:
|
|
204
|
+
content += f"{summary}\n"
|
|
205
|
+
if observations:
|
|
206
|
+
content += "\nRecent observations:\n" + "\n".join(observations)
|
|
207
|
+
|
|
208
|
+
results.append({
|
|
209
|
+
"content": content,
|
|
210
|
+
"title": name,
|
|
211
|
+
"tool_id": "knowledge_graph",
|
|
212
|
+
"relative_path": f"entity/{etype}/{name}",
|
|
213
|
+
"date": "",
|
|
214
|
+
"score": 0.7,
|
|
215
|
+
"source": "graph",
|
|
216
|
+
})
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def hybrid_search(
|
|
221
|
+
db: AsyncSession,
|
|
222
|
+
query: str,
|
|
223
|
+
limit: int = 5,
|
|
224
|
+
tool_filter: str | None = None,
|
|
225
|
+
days: int | None = None,
|
|
226
|
+
user_id: uuid.UUID | None = None,
|
|
227
|
+
) -> list[dict]:
|
|
228
|
+
"""Combined semantic + full-text + graph search with deduplication."""
|
|
229
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
|
230
|
+
|
|
231
|
+
# Get user's machine IDs for data isolation
|
|
232
|
+
user_machine_ids = None
|
|
233
|
+
if user_id:
|
|
234
|
+
result = await db.execute(select(Machine.id).where(Machine.user_id == user_id))
|
|
235
|
+
user_machine_ids = [r[0] for r in result.all()]
|
|
236
|
+
|
|
237
|
+
# Run all search strategies
|
|
238
|
+
semantic_results = await _semantic_search(db, query, limit * 2, user_machine_ids, tool_filter, cutoff)
|
|
239
|
+
fulltext_results = await _fulltext_search(db, query, limit * 2, user_machine_ids, tool_filter, cutoff)
|
|
240
|
+
graph_results = await _graph_search(db, query, limit, user_id)
|
|
241
|
+
|
|
242
|
+
# Merge and deduplicate by relative_path
|
|
243
|
+
seen = set()
|
|
244
|
+
merged = []
|
|
245
|
+
for r in sorted(semantic_results + fulltext_results + graph_results, key=lambda x: -x["score"]):
|
|
246
|
+
key = r["relative_path"]
|
|
247
|
+
if key in seen:
|
|
248
|
+
continue
|
|
249
|
+
seen.add(key)
|
|
250
|
+
merged.append(r)
|
|
251
|
+
if len(merged) >= limit:
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
return merged
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""MCP Memory Server — exposes personal AI memory via Model Context Protocol.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
- Remote (--server + --token): calls Memento server REST API. No DB needed.
|
|
5
|
+
- Direct (--db-url): connects directly to PostgreSQL. For local dev/self-hosted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import date, datetime, timedelta, timezone
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("mcp_memory")
|
|
16
|
+
|
|
17
|
+
mcp = FastMCP(
|
|
18
|
+
"Memento",
|
|
19
|
+
instructions="Personal AI memory — search conversations, recall knowledge, explore project context from your Memento data.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Initialized on startup
|
|
23
|
+
_remote = None # RemoteClient for HTTP mode
|
|
24
|
+
_session_factory = None # SQLAlchemy session factory for DB mode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_server(server_url: str | None = None, token: str | None = None, db_url: str | None = None):
|
|
28
|
+
"""Initialize the MCP server. Either (server_url + token) or db_url."""
|
|
29
|
+
global _remote, _session_factory
|
|
30
|
+
if server_url and token:
|
|
31
|
+
from .remote_client import RemoteClient
|
|
32
|
+
_remote = RemoteClient(server_url, token)
|
|
33
|
+
logger.info("MCP Memory Server initialized in remote mode: %s", server_url)
|
|
34
|
+
elif db_url:
|
|
35
|
+
from .db import create_engine_and_session
|
|
36
|
+
_session_factory = create_engine_and_session(db_url)
|
|
37
|
+
logger.info("MCP Memory Server initialized in direct DB mode")
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError("Either --server/--token or --db-url required")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Tools
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
async def memory_search(
|
|
48
|
+
query: str,
|
|
49
|
+
limit: int = 5,
|
|
50
|
+
tool_filter: str | None = None,
|
|
51
|
+
days: int | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Search your personal AI memory using semantic + full-text hybrid search.
|
|
54
|
+
|
|
55
|
+
Use this to find past conversations, decisions, solutions, and knowledge
|
|
56
|
+
from all your AI tools (Claude Code, Cursor, Codex, Windsurf, etc.).
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
query: Natural language search query
|
|
60
|
+
limit: Max results to return (default 5)
|
|
61
|
+
tool_filter: Optional tool filter (claude_code, codex, cursor, antigravity, openclaw, obsidian)
|
|
62
|
+
days: Optional time filter — only search last N days
|
|
63
|
+
"""
|
|
64
|
+
if _remote:
|
|
65
|
+
try:
|
|
66
|
+
results = await _remote.search(query, limit=limit, tool_filter=tool_filter)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return f"Search failed: {e}"
|
|
69
|
+
if not results:
|
|
70
|
+
return "No matching memories found."
|
|
71
|
+
parts = []
|
|
72
|
+
for i, r in enumerate(results, 1):
|
|
73
|
+
title = r.get('title') or r.get('relative_path', 'Untitled')
|
|
74
|
+
snippet = r.get('snippet', '') or ''
|
|
75
|
+
parts.append(
|
|
76
|
+
f"## Result {i}: {title} ({r.get('tool_id', '')})\n"
|
|
77
|
+
f"**Source**: {r.get('relative_path', '')}\n"
|
|
78
|
+
f"**Date**: {(r.get('synced_at') or '')[:10]}\n\n"
|
|
79
|
+
f"{snippet}\n"
|
|
80
|
+
)
|
|
81
|
+
return "\n---\n\n".join(parts)
|
|
82
|
+
|
|
83
|
+
# Direct DB mode
|
|
84
|
+
from .search import hybrid_search
|
|
85
|
+
async with _session_factory() as db:
|
|
86
|
+
results = await hybrid_search(db, query, limit=limit, tool_filter=tool_filter, days=days)
|
|
87
|
+
if not results:
|
|
88
|
+
return "No matching memories found."
|
|
89
|
+
parts = []
|
|
90
|
+
for i, r in enumerate(results, 1):
|
|
91
|
+
parts.append(
|
|
92
|
+
f"## Result {i}: {r['title']} ({r['tool_id']})\n"
|
|
93
|
+
f"**Source**: {r['relative_path']}\n"
|
|
94
|
+
f"**Date**: {r['date']}\n\n"
|
|
95
|
+
f"{r['content']}\n"
|
|
96
|
+
)
|
|
97
|
+
return "\n---\n\n".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
async def memory_recall(
|
|
102
|
+
category: str = "conversation",
|
|
103
|
+
project: str | None = None,
|
|
104
|
+
days: int = 7,
|
|
105
|
+
limit: int = 10,
|
|
106
|
+
date: str | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Recall recent memories by category, project, and optional date.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
category: Memory category (conversation, memory, identity, plan, config, learning, skill, note)
|
|
112
|
+
project: Optional project name filter
|
|
113
|
+
days: How far back to look (default 7 days, ignored if date given)
|
|
114
|
+
limit: Max items to return
|
|
115
|
+
date: Optional specific date (YYYY-MM-DD), overrides days
|
|
116
|
+
"""
|
|
117
|
+
if _remote:
|
|
118
|
+
# If specific date given, use daily endpoint
|
|
119
|
+
if date:
|
|
120
|
+
try:
|
|
121
|
+
data = await _remote.get_daily(date)
|
|
122
|
+
conversations = data.get("overview", {}).get("conversations", [])
|
|
123
|
+
if not conversations:
|
|
124
|
+
return f"No conversations on {date}."
|
|
125
|
+
parts = [f"# Conversations on {date}\n"]
|
|
126
|
+
for c in conversations[:limit]:
|
|
127
|
+
title = c.get("title") or c.get("id", "")[:8]
|
|
128
|
+
u = c.get("user_messages", 0)
|
|
129
|
+
a = c.get("assistant_messages", 0)
|
|
130
|
+
parts.append(f"- [{c.get('tool_id', '')}] **{title}** ({u}↑ {a}↓)")
|
|
131
|
+
return "\n".join(parts)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return f"Could not load conversations for {date}: {e}"
|
|
134
|
+
|
|
135
|
+
# Otherwise use search-style: list recent files of category
|
|
136
|
+
try:
|
|
137
|
+
tools = await _remote.get_tools()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return f"Failed to list tools: {e}"
|
|
140
|
+
|
|
141
|
+
from datetime import datetime as _dt
|
|
142
|
+
cutoff_str = (_dt.utcnow() - timedelta(days=days)).isoformat()
|
|
143
|
+
all_files = []
|
|
144
|
+
for tool in tools:
|
|
145
|
+
try:
|
|
146
|
+
files = await _remote.get_tool_files(tool.get("id", ""), category=category)
|
|
147
|
+
for f in files:
|
|
148
|
+
f["_tool_id"] = tool.get("id", "")
|
|
149
|
+
all_files.append(f)
|
|
150
|
+
except Exception:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# Filter by date and project
|
|
154
|
+
filtered = [f for f in all_files if (f.get("synced_at") or "") >= cutoff_str]
|
|
155
|
+
if project:
|
|
156
|
+
filtered = [f for f in filtered if project.lower() in (f.get("relative_path") or "").lower()]
|
|
157
|
+
|
|
158
|
+
# Sort by synced_at desc
|
|
159
|
+
filtered.sort(key=lambda f: f.get("synced_at", ""), reverse=True)
|
|
160
|
+
|
|
161
|
+
if not filtered:
|
|
162
|
+
return f"No {category} memories in last {days} days."
|
|
163
|
+
|
|
164
|
+
parts = [f"# Recent {category} (last {days} days)\n"]
|
|
165
|
+
for f in filtered[:limit]:
|
|
166
|
+
title = f.get("title") or f.get("relative_path", "")
|
|
167
|
+
d = (f.get("synced_at") or "")[:10]
|
|
168
|
+
parts.append(f"- [{f['_tool_id']}] **{title}** — {d}")
|
|
169
|
+
return "\n".join(parts)
|
|
170
|
+
|
|
171
|
+
# Direct DB mode
|
|
172
|
+
from sqlalchemy import select
|
|
173
|
+
from .db import Document, Project
|
|
174
|
+
async with _session_factory() as db:
|
|
175
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
176
|
+
q = (
|
|
177
|
+
select(Document.title, Document.tool_id, Document.relative_path,
|
|
178
|
+
Document.content, Document.synced_at)
|
|
179
|
+
.where(Document.category == category, Document.synced_at >= cutoff)
|
|
180
|
+
.order_by(Document.synced_at.desc())
|
|
181
|
+
.limit(limit)
|
|
182
|
+
)
|
|
183
|
+
if project:
|
|
184
|
+
q = q.join(Project, Document.project_id == Project.id).where(
|
|
185
|
+
Project.title.ilike(f"%{project}%")
|
|
186
|
+
)
|
|
187
|
+
result = await db.execute(q)
|
|
188
|
+
rows = result.all()
|
|
189
|
+
if not rows:
|
|
190
|
+
return f"No {category} memories found in the last {days} days."
|
|
191
|
+
parts = []
|
|
192
|
+
for title, tool_id, path, content, synced_at in rows:
|
|
193
|
+
preview = (content or "")[:500]
|
|
194
|
+
parts.append(
|
|
195
|
+
f"### {title or path} ({tool_id})\n"
|
|
196
|
+
f"*{synced_at.strftime('%Y-%m-%d %H:%M')}*\n\n"
|
|
197
|
+
f"{preview}{'...' if len(content or '') > 500 else ''}\n"
|
|
198
|
+
)
|
|
199
|
+
return "\n---\n\n".join(parts)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@mcp.tool()
|
|
203
|
+
async def memory_context(project_name: str) -> str:
|
|
204
|
+
"""Get comprehensive project context — recent conversations, plans, and memory files.
|
|
205
|
+
|
|
206
|
+
Use this when starting work on a project to load all relevant context.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
project_name: Project name to look up
|
|
210
|
+
"""
|
|
211
|
+
if _remote:
|
|
212
|
+
projects = await _remote.list_projects()
|
|
213
|
+
matched = [p for p in projects if project_name.lower() in (p.get("title", "") or "").lower()]
|
|
214
|
+
if not matched:
|
|
215
|
+
return f"No project found matching '{project_name}'."
|
|
216
|
+
project = await _remote.get_project(matched[0]["id"])
|
|
217
|
+
parts = [f"# Project: {project.get('title', project_name)}"]
|
|
218
|
+
parts.append(f"**Tool**: {project.get('tool_id', '')}")
|
|
219
|
+
if project.get("source_path"):
|
|
220
|
+
parts.append(f"**Path**: `{project['source_path']}`")
|
|
221
|
+
docs = project.get("documents", [])
|
|
222
|
+
if docs:
|
|
223
|
+
parts.append(f"\n## Documents ({len(docs)})")
|
|
224
|
+
for d in docs[:10]:
|
|
225
|
+
parts.append(f"- [{d.get('category', '')}] {d.get('title', d.get('relative_path', ''))}")
|
|
226
|
+
return "\n".join(parts)
|
|
227
|
+
|
|
228
|
+
# Direct DB mode
|
|
229
|
+
from .graph import get_entity_context
|
|
230
|
+
async with _session_factory() as db:
|
|
231
|
+
return await get_entity_context(db, project_name)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@mcp.tool()
|
|
235
|
+
async def memory_store(
|
|
236
|
+
content: str,
|
|
237
|
+
entity_name: str | None = None,
|
|
238
|
+
entity_type: str = "concept",
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Store a new observation or fact in your personal memory.
|
|
241
|
+
|
|
242
|
+
Use this to save important decisions, learnings, or context for later recall.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
content: The fact or observation to remember
|
|
246
|
+
entity_name: Optional entity this relates to (e.g. project name, technology)
|
|
247
|
+
entity_type: Entity type (project/tool/concept/person/technology)
|
|
248
|
+
"""
|
|
249
|
+
if _remote:
|
|
250
|
+
return f"Noted: {content[:200]}... (remote storage coming soon)"
|
|
251
|
+
|
|
252
|
+
from .graph import store_observation
|
|
253
|
+
async with _session_factory() as db:
|
|
254
|
+
result = await store_observation(db, content, entity_name=entity_name, entity_type=entity_type)
|
|
255
|
+
await db.commit()
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@mcp.tool()
|
|
260
|
+
async def daily_summary(date_str: str | None = None) -> str:
|
|
261
|
+
"""Get daily activity summary for a specific date.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
date_str: Date in YYYY-MM-DD format (default: today)
|
|
265
|
+
"""
|
|
266
|
+
target = date_str or date.today().isoformat()
|
|
267
|
+
|
|
268
|
+
if _remote:
|
|
269
|
+
try:
|
|
270
|
+
data = await _remote.get_daily(target)
|
|
271
|
+
total = data.get("total_messages", 0)
|
|
272
|
+
if total == 0:
|
|
273
|
+
return f"No activity recorded on {target}."
|
|
274
|
+
|
|
275
|
+
tool_stats = data.get("overview", {}).get("tool_stats", {})
|
|
276
|
+
conversations = data.get("overview", {}).get("conversations", [])
|
|
277
|
+
summaries = data.get("summaries", [])
|
|
278
|
+
|
|
279
|
+
parts = [f"# Activity Summary — {target}", f"\n**Total messages**: {total}\n"]
|
|
280
|
+
|
|
281
|
+
# Tool breakdown
|
|
282
|
+
parts.append("## Tools")
|
|
283
|
+
for tool, count in sorted(tool_stats.items(), key=lambda x: -x[1]):
|
|
284
|
+
parts.append(f"- **{tool}**: {count} messages")
|
|
285
|
+
|
|
286
|
+
# Top conversations (real titles, not just counts)
|
|
287
|
+
if conversations:
|
|
288
|
+
parts.append("\n## Top Conversations")
|
|
289
|
+
for c in conversations[:10]:
|
|
290
|
+
title = c.get("title") or c.get("id", "")[:8]
|
|
291
|
+
u = c.get("user_messages", 0)
|
|
292
|
+
a = c.get("assistant_messages", 0)
|
|
293
|
+
parts.append(f"- [{c.get('tool_id', '')}] **{title}** ({u}↑ {a}↓)")
|
|
294
|
+
|
|
295
|
+
# AI summary if exists
|
|
296
|
+
if summaries:
|
|
297
|
+
for s in summaries:
|
|
298
|
+
parts.append(f"\n## {s.get('title', 'AI Summary')}\n{s.get('summary', '')}")
|
|
299
|
+
else:
|
|
300
|
+
parts.append("\n*No AI summary yet. Generate one at https://report.ihasy.com/daily/" + target + "*")
|
|
301
|
+
|
|
302
|
+
return "\n".join(parts)
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return f"Could not load daily summary for {target}: {e}"
|
|
305
|
+
|
|
306
|
+
# Direct DB mode
|
|
307
|
+
from sqlalchemy import select, func, cast, Date as SqlDate
|
|
308
|
+
from .db import DailySummary, ConversationMessage, Document
|
|
309
|
+
target_date = date.fromisoformat(target)
|
|
310
|
+
async with _session_factory() as db:
|
|
311
|
+
result = await db.execute(
|
|
312
|
+
select(DailySummary).where(
|
|
313
|
+
DailySummary.summary_date == target_date, DailySummary.tool_id.is_(None)
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
summary = result.scalar_one_or_none()
|
|
317
|
+
if summary:
|
|
318
|
+
return f"# AI Daily Summary — {target}\n\n{summary.summary}"
|
|
319
|
+
|
|
320
|
+
tz_cst = timezone(timedelta(hours=8))
|
|
321
|
+
day_start = datetime(target_date.year, target_date.month, target_date.day, tzinfo=tz_cst)
|
|
322
|
+
day_end = day_start + timedelta(days=1)
|
|
323
|
+
msg_result = await db.execute(
|
|
324
|
+
select(Document.tool_id, func.count())
|
|
325
|
+
.join(ConversationMessage, ConversationMessage.document_id == Document.id)
|
|
326
|
+
.where(ConversationMessage.timestamp >= day_start, ConversationMessage.timestamp < day_end,
|
|
327
|
+
ConversationMessage.role.in_(["user", "assistant"]))
|
|
328
|
+
.group_by(Document.tool_id)
|
|
329
|
+
)
|
|
330
|
+
stats = {r[0]: r[1] for r in msg_result.all()}
|
|
331
|
+
if not stats:
|
|
332
|
+
return f"No activity recorded on {target}."
|
|
333
|
+
total = sum(stats.values())
|
|
334
|
+
lines = [f"# Activity Summary — {target}", f"Total messages: {total}\n"]
|
|
335
|
+
for tool, count in sorted(stats.items(), key=lambda x: -x[1]):
|
|
336
|
+
lines.append(f"- **{tool}**: {count} messages")
|
|
337
|
+
return "\n".join(lines)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Resources
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@mcp.resource("memory://projects")
|
|
345
|
+
async def list_projects() -> str:
|
|
346
|
+
"""List all projects with document counts."""
|
|
347
|
+
if _remote:
|
|
348
|
+
projects = await _remote.list_projects()
|
|
349
|
+
if not projects:
|
|
350
|
+
return "No projects found."
|
|
351
|
+
lines = ["# Projects\n"]
|
|
352
|
+
for p in projects[:50]:
|
|
353
|
+
lines.append(f"- **{p.get('title', '')}** ({p.get('tool_id', '')}) — {p.get('document_count', 0)} files")
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
from sqlalchemy import select, func
|
|
357
|
+
from .db import Project, Document
|
|
358
|
+
async with _session_factory() as db:
|
|
359
|
+
result = await db.execute(
|
|
360
|
+
select(Project.title, Project.tool_id, Project.source_path,
|
|
361
|
+
func.count(Document.id).label("doc_count"))
|
|
362
|
+
.outerjoin(Document, Document.project_id == Project.id)
|
|
363
|
+
.group_by(Project.id)
|
|
364
|
+
.order_by(func.count(Document.id).desc())
|
|
365
|
+
.limit(50)
|
|
366
|
+
)
|
|
367
|
+
rows = result.all()
|
|
368
|
+
if not rows:
|
|
369
|
+
return "No projects found."
|
|
370
|
+
lines = ["# Projects\n"]
|
|
371
|
+
for title, tool_id, source_path, count in rows:
|
|
372
|
+
lines.append(f"- **{title}** ({tool_id}) — {count} files — `{source_path or ''}`")
|
|
373
|
+
return "\n".join(lines)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@mcp.resource("memory://projects/{name}")
|
|
377
|
+
async def get_project(name: str) -> str:
|
|
378
|
+
"""Get project details."""
|
|
379
|
+
return await memory_context(name)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@mcp.resource("memory://identity/{tool}")
|
|
383
|
+
async def get_identity(tool: str) -> str:
|
|
384
|
+
"""Get tool identity files (AGENTS.md, SOUL.md, etc.)."""
|
|
385
|
+
if _remote:
|
|
386
|
+
files = await _remote.get_tool_files(tool, category="identity")
|
|
387
|
+
if not files:
|
|
388
|
+
return f"No identity files found for {tool}."
|
|
389
|
+
parts = []
|
|
390
|
+
for f in files:
|
|
391
|
+
doc = await _remote.get_document(f["id"])
|
|
392
|
+
parts.append(f"## {doc.get('title', '')}\n\n{doc.get('content', '(empty)')}")
|
|
393
|
+
return "\n\n---\n\n".join(parts)
|
|
394
|
+
|
|
395
|
+
from sqlalchemy import select
|
|
396
|
+
from .db import Document
|
|
397
|
+
async with _session_factory() as db:
|
|
398
|
+
result = await db.execute(
|
|
399
|
+
select(Document.title, Document.content)
|
|
400
|
+
.where(Document.tool_id == tool, Document.category == "identity")
|
|
401
|
+
.order_by(Document.synced_at.desc())
|
|
402
|
+
)
|
|
403
|
+
rows = result.all()
|
|
404
|
+
if not rows:
|
|
405
|
+
return f"No identity files found for {tool}."
|
|
406
|
+
parts = []
|
|
407
|
+
for title, content in rows:
|
|
408
|
+
parts.append(f"## {title}\n\n{content or '(empty)'}")
|
|
409
|
+
return "\n\n---\n\n".join(parts)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@mcp.resource("memory://daily/{date_str}")
|
|
413
|
+
async def get_daily(date_str: str) -> str:
|
|
414
|
+
"""Get daily report for a specific date."""
|
|
415
|
+
return await daily_summary(date_str)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "memento-brain-memory"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "MCP Memory Server — personal AI memory powered by Memento data"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{name = "ddong8"}]
|
|
9
|
+
keywords = ["memento", "mcp", "memory", "ai", "claude", "knowledge-graph"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Software Development :: Libraries",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"mcp>=1.26",
|
|
18
|
+
"asyncpg>=0.30",
|
|
19
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
20
|
+
"pgvector>=0.3",
|
|
21
|
+
"openai>=1.30",
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/ddong8/memento"
|
|
27
|
+
Repository = "https://github.com/ddong8/memento"
|
|
28
|
+
Issues = "https://github.com/ddong8/memento/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
# Primary — matches pkg name
|
|
32
|
+
memento-brain-memory = "mcp_server.__main__:main"
|
|
33
|
+
# Short alias — back-compat and convenience
|
|
34
|
+
memento-memory = "mcp_server.__main__:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["mcp_server"]
|