Skip to content

Commit 74f3785

Browse files
SonAIengineclaude
andcommitted
feat: v0.4.0 — MCP 서버 (7개 tool) + synaptic-mcp CLI
MCP 서버 (src/synaptic/mcp/): - knowledge_search: 하이브리드 검색 (FTS+fuzzy+synonym+spreading activation) - knowledge_add: 노드 추가 (9종 NodeKind, auto tag 추출) - knowledge_link: 엣지 생성 (7종 EdgeKind, weight 설정) - knowledge_reinforce: Hebbian 학습 (success/failure co-activation) - knowledge_stats: 그래프 통계 (kind/level별 노드 수, 캐시 hit rate) - knowledge_export: Markdown/JSON 내보내기 - knowledge_consolidate: L0→L3 수명주기 + vitality decay + edge pruning 실행 방법: synaptic-mcp # stdio (Claude Code 연동) synaptic-mcp --db ./knowledge.db # SQLite 경로 지정 synaptic-mcp --dsn postgresql://... # PostgreSQL 백엔드 pyproject.toml: - [project.scripts] synaptic-mcp 엔트리포인트 - mcp extra: mcp[cli]>=1.5 + aiosqlite - all extra에 mcp 포함 145 unit+QA tests passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8657216 commit 74f3785

File tree

6 files changed

+5868
-548
lines changed

6 files changed

+5868
-548
lines changed

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "synaptic-memory"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Brain-inspired knowledge graph: spreading activation, Hebbian learning, memory consolidation."
99
license = "MIT"
1010
requires-python = ">=3.12"
@@ -28,10 +28,14 @@ Homepage = "https://github.com/PlateerLab/synaptic-memory"
2828
Repository = "https://github.com/PlateerLab/synaptic-memory"
2929
Changelog = "https://github.com/PlateerLab/synaptic-memory/blob/main/CHANGELOG.md"
3030

31+
[project.scripts]
32+
synaptic-mcp = "synaptic.mcp.server:main"
33+
3134
[project.optional-dependencies]
3235
postgresql = ["asyncpg>=0.30", "pgvector>=0.3"]
3336
sqlite = ["aiosqlite>=0.20"]
34-
all = ["asyncpg>=0.30", "pgvector>=0.3", "aiosqlite>=0.20"]
37+
mcp = ["mcp[cli]>=1.5", "aiosqlite>=0.20"]
38+
all = ["asyncpg>=0.30", "pgvector>=0.3", "aiosqlite>=0.20", "mcp[cli]>=1.5"]
3539
dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "ruff>=0.8", "pyright>=1.1"]
3640

3741
[tool.hatch.build.targets.wheel]

src/synaptic/mcp/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Synaptic Memory MCP Server."""
2+
3+
__version__ = "0.4.0"

src/synaptic/mcp/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Allow running with python -m synaptic.mcp."""
2+
3+
from synaptic.mcp.server import main
4+
5+
main()

src/synaptic/mcp/server.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""Synaptic Memory MCP Server — expose knowledge graph as MCP tools.
2+
3+
Usage:
4+
synaptic-mcp # stdio (default, for Claude Code)
5+
synaptic-mcp --db ./knowledge.db # custom DB path
6+
synaptic-mcp --dsn postgresql://... # PostgreSQL backend
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import logging
12+
import sys
13+
from typing import Any
14+
15+
from mcp.server.fastmcp import FastMCP
16+
17+
from synaptic.mcp import __version__
18+
19+
logger = logging.getLogger("synaptic.mcp")
20+
21+
server = FastMCP(
22+
"Synaptic Memory",
23+
dependencies=["aiosqlite"],
24+
)
25+
26+
# Module-level state (initialized on first tool call)
27+
_graph: Any = None
28+
_backend: Any = None
29+
_db_path: str = "knowledge.db"
30+
_dsn: str = ""
31+
32+
33+
async def _ensure_graph() -> Any:
34+
"""Lazy-initialize the SynapticGraph on first use."""
35+
global _graph, _backend
36+
37+
if _graph is not None:
38+
return _graph
39+
40+
from synaptic.extensions.tagger_regex import RegexTagExtractor # noqa: PLC0415
41+
from synaptic.graph import SynapticGraph # noqa: PLC0415
42+
43+
if _dsn:
44+
from synaptic.backends.postgresql import PostgreSQLBackend # noqa: PLC0415
45+
46+
_backend = PostgreSQLBackend(_dsn)
47+
else:
48+
from synaptic.backends.sqlite import SQLiteBackend # noqa: PLC0415
49+
50+
_backend = SQLiteBackend(_db_path)
51+
52+
await _backend.connect()
53+
_graph = SynapticGraph(_backend, tag_extractor=RegexTagExtractor())
54+
logger.info("Knowledge graph initialized (backend=%s)", type(_backend).__name__)
55+
return _graph
56+
57+
58+
# --- Tools ---
59+
60+
61+
@server.tool()
62+
async def knowledge_search(
63+
query: str,
64+
limit: int = 10,
65+
) -> dict[str, Any]:
66+
"""Search the knowledge graph for lessons, decisions, patterns, and past outcomes.
67+
68+
Use this to find relevant company knowledge before starting a task.
69+
Supports Korean and English queries with synonym expansion.
70+
71+
Args:
72+
query: Search query (Korean or English)
73+
limit: Maximum number of results to return
74+
"""
75+
graph = await _ensure_graph()
76+
result = await graph.search(query, limit=limit)
77+
78+
if not result.nodes:
79+
return {"success": True, "message": "No knowledge found for this query.", "results": []}
80+
81+
results = []
82+
for activated in result.nodes:
83+
node = activated.node
84+
results.append(
85+
{
86+
"id": node.id,
87+
"kind": str(node.kind),
88+
"title": node.title,
89+
"content": node.content[:500],
90+
"tags": node.tags,
91+
"level": str(node.level),
92+
"score": round(activated.resonance, 3),
93+
}
94+
)
95+
96+
return {
97+
"success": True,
98+
"results": results,
99+
"total_candidates": result.total_candidates,
100+
"search_time_ms": round(result.search_time_ms, 1),
101+
"stages_used": result.stages_used,
102+
}
103+
104+
105+
@server.tool()
106+
async def knowledge_add(
107+
title: str,
108+
content: str,
109+
kind: str = "concept",
110+
tags: str = "",
111+
source: str = "",
112+
) -> dict[str, Any]:
113+
"""Add a new knowledge node to the graph.
114+
115+
Args:
116+
title: Node title (concise summary)
117+
content: Full content/description
118+
kind: Node type — concept, entity, lesson, decision, rule, artifact, agent, task, sprint
119+
tags: Comma-separated tags (e.g. "deploy,ci/cd,automation")
120+
source: Origin of this knowledge (e.g. "sprint:123", "manual")
121+
"""
122+
from synaptic.models import NodeKind # noqa: PLC0415
123+
124+
graph = await _ensure_graph()
125+
126+
try:
127+
node_kind = NodeKind(kind)
128+
except ValueError:
129+
return {"success": False, "message": f"Invalid kind: {kind}. Use: {', '.join(NodeKind)}"}
130+
131+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
132+
133+
node = await graph.add(
134+
title=title,
135+
content=content,
136+
kind=node_kind,
137+
tags=tag_list,
138+
source=source,
139+
)
140+
141+
return {
142+
"success": True,
143+
"node_id": node.id,
144+
"title": node.title,
145+
"kind": str(node.kind),
146+
"tags": node.tags,
147+
}
148+
149+
150+
@server.tool()
151+
async def knowledge_link(
152+
source_id: str,
153+
target_id: str,
154+
kind: str = "related",
155+
weight: float = 1.0,
156+
) -> dict[str, Any]:
157+
"""Create a link between two knowledge nodes.
158+
159+
Args:
160+
source_id: Source node ID
161+
target_id: Target node ID
162+
kind: Edge type (related/caused/learned_from/depends_on/produced/contradicts/supersedes)
163+
weight: Connection strength (0.0 to 5.0)
164+
"""
165+
from synaptic.models import EdgeKind # noqa: PLC0415
166+
167+
graph = await _ensure_graph()
168+
169+
try:
170+
edge_kind = EdgeKind(kind)
171+
except ValueError:
172+
return {"success": False, "message": f"Invalid kind: {kind}. Use: {', '.join(EdgeKind)}"}
173+
174+
edge = await graph.link(source_id, target_id, kind=edge_kind, weight=weight)
175+
176+
return {
177+
"success": True,
178+
"edge_id": edge.id,
179+
"source_id": edge.source_id,
180+
"target_id": edge.target_id,
181+
"kind": str(edge.kind),
182+
"weight": edge.weight,
183+
}
184+
185+
186+
@server.tool()
187+
async def knowledge_reinforce(
188+
node_ids: str,
189+
success: bool = True,
190+
) -> dict[str, Any]:
191+
"""Reinforce knowledge nodes after use (Hebbian learning).
192+
193+
Strengthens connections between co-activated nodes on success,
194+
weakens them on failure.
195+
196+
Args:
197+
node_ids: Comma-separated node IDs to reinforce
198+
success: True if the knowledge was useful, False if not
199+
"""
200+
graph = await _ensure_graph()
201+
ids = [nid.strip() for nid in node_ids.split(",") if nid.strip()]
202+
if not ids:
203+
return {"success": False, "message": "No node IDs provided"}
204+
205+
await graph.reinforce(ids, success=success)
206+
return {
207+
"success": True,
208+
"reinforced": len(ids),
209+
"outcome": "success" if success else "failure",
210+
}
211+
212+
213+
@server.tool()
214+
async def knowledge_stats() -> dict[str, Any]:
215+
"""Get knowledge graph statistics — node counts by kind and level, cache stats."""
216+
graph = await _ensure_graph()
217+
stats = await graph.stats()
218+
return {"success": True, **{k: v for k, v in stats.items()}}
219+
220+
221+
@server.tool()
222+
async def knowledge_export(
223+
output_format: str = "markdown",
224+
) -> dict[str, Any]:
225+
"""Export the knowledge graph.
226+
227+
Args:
228+
output_format: Export format — "markdown" or "json"
229+
"""
230+
graph = await _ensure_graph()
231+
232+
if output_format == "json":
233+
content = await graph.export_json()
234+
else:
235+
content = await graph.export_markdown()
236+
237+
return {"success": True, "format": output_format, "content": content}
238+
239+
240+
@server.tool()
241+
async def knowledge_consolidate() -> dict[str, Any]:
242+
"""Run memory consolidation — expire old L0 nodes, promote accessed ones.
243+
244+
L0 (72h TTL) → L1 (accessed 3+) → L2 (accessed 10+) → L3 (permanent, 80%+ success rate).
245+
Also runs vitality decay and edge pruning.
246+
"""
247+
graph = await _ensure_graph()
248+
result = await graph.consolidate()
249+
decayed = await graph.decay()
250+
pruned = await graph.prune()
251+
252+
return {
253+
"success": True,
254+
"nodes_promoted": len(result.nodes_updated),
255+
"nodes_created": len(result.nodes_created),
256+
"vitality_decayed": decayed,
257+
"edges_pruned": pruned,
258+
}
259+
260+
261+
def main() -> None:
262+
"""Entry point for synaptic-mcp command."""
263+
global _db_path, _dsn
264+
265+
if "--version" in sys.argv:
266+
print(f"synaptic-mcp {__version__}")
267+
return
268+
269+
if "--help" in sys.argv or "-h" in sys.argv:
270+
print(
271+
"Usage: synaptic-mcp [--db PATH] [--dsn DSN]\n"
272+
"\n"
273+
"Options:\n"
274+
" --db PATH SQLite database path (default: knowledge.db)\n"
275+
" --dsn DSN PostgreSQL connection string\n"
276+
" --version Show version\n"
277+
)
278+
return
279+
280+
# Parse --db and --dsn args
281+
args = sys.argv[1:]
282+
for i, arg in enumerate(args):
283+
if arg == "--db" and i + 1 < len(args):
284+
_db_path = args[i + 1]
285+
elif arg == "--dsn" and i + 1 < len(args):
286+
_dsn = args[i + 1]
287+
288+
# Configure logging to stderr (stdout is MCP protocol)
289+
logging.basicConfig(
290+
level=logging.INFO,
291+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
292+
stream=sys.stderr,
293+
)
294+
295+
logger.info("Starting Synaptic Memory MCP server (db=%s, dsn=%s)", _db_path, _dsn or "none")
296+
server.run()
297+
298+
299+
if __name__ == "__main__":
300+
main()

0 commit comments

Comments
 (0)