@luisarg/memory-auto 0.1.0 → 0.1.1

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.
@@ -0,0 +1,399 @@
1
+ """MCP server exposing memory store tools via the Model Context Protocol.
2
+
3
+ Eight tools (per openspec/specs/memory-mcp-server/spec.md):
4
+ search_memory, store_decision, store_fact, store_learning,
5
+ store_convention, store_profile, export_memories, get_profile, ping.
6
+
7
+ All reads are explicit (no background polling). Server validates storage
8
+ accessibility at startup.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from mcp.server import Server
19
+ from mcp.server.lowlevel.server import ServerRequestContext
20
+ from mcp.server.stdio import stdio_server
21
+ from mcp.types import (
22
+ CallToolRequestParams,
23
+ CallToolResult,
24
+ ListToolsResult,
25
+ PaginatedRequestParams,
26
+ TextContent,
27
+ Tool,
28
+ )
29
+
30
+ from store import (
31
+ VALID_TYPES,
32
+ MemoryStore,
33
+ _first_non_heading_line,
34
+ )
35
+
36
+ # Observability log: append-only JSONL. Path is derived from MEMORY_PATH so the
37
+ # log lives next to the bundle, never blocking tool behavior.
38
+ _LOG_PATH: Path | None = None
39
+
40
+
41
+ def _log_path() -> Path:
42
+ global _LOG_PATH
43
+ if _LOG_PATH is not None:
44
+ return _LOG_PATH
45
+ from cli import get_memory_path
46
+
47
+ base = get_memory_path()
48
+ _LOG_PATH = base / "tool-calls.log"
49
+ return _LOG_PATH
50
+
51
+
52
+ def _log_tool_call(tool_name: str, params: dict) -> None:
53
+ """Append a JSONL entry for each tool call. Silently ignore write errors."""
54
+ try:
55
+ entry = {
56
+ "timestamp": datetime.now(timezone.utc).isoformat(),
57
+ "tool": tool_name,
58
+ "project": params.get("project"),
59
+ "entry_type": params.get("entry_type"),
60
+ }
61
+ path = _log_path()
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ with open(path, "a") as f:
64
+ f.write(json.dumps(entry, default=str) + "\n")
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ def _require(params: dict, key: str) -> str:
70
+ val = params.get(key)
71
+ if not val:
72
+ raise ValueError(f"'{key}' is required and must not be empty")
73
+ return val
74
+
75
+
76
+ def _derive_description(content: str) -> str:
77
+ """Derive a one-sentence description from the first non-heading line."""
78
+ return _first_non_heading_line(content) or content[:120]
79
+
80
+
81
+ # ── Tool handlers (sync, return JSON strings) ──────────────────────────────
82
+
83
+
84
+ def handle_search_memory(store: MemoryStore, params: dict) -> str:
85
+ project = params.get("project") or None
86
+ entry_type = params.get("entry_type") or None
87
+ tags = params.get("tags") or None
88
+ query = params.get("query") or None
89
+ results = store.search_entries(
90
+ project=project, entry_type=entry_type, tags=tags, query=query
91
+ )
92
+ return json.dumps(results)
93
+
94
+
95
+ def _handle_store_typed(
96
+ store: MemoryStore, params: dict, entry_type: str
97
+ ) -> str:
98
+ project = _require(params, "project")
99
+ content = _require(params, "content")
100
+ tags = params.get("tags") or []
101
+ description = params.get("description")
102
+ if not description or not str(description).strip():
103
+ description = _derive_description(content)
104
+ openspec_change_id = params.get("openspec_change_id") or None
105
+ confidence = params.get("confidence", 1.0)
106
+ if confidence is None:
107
+ confidence = 1.0
108
+ entry = store.upsert_entry(
109
+ entry_type=entry_type,
110
+ project=project,
111
+ content=content,
112
+ tags=tags,
113
+ description=str(description).strip(),
114
+ confidence=float(confidence),
115
+ openspec_change_id=openspec_change_id,
116
+ )
117
+ return json.dumps(entry)
118
+
119
+
120
+ def handle_store_decision(store: MemoryStore, params: dict) -> str:
121
+ return _handle_store_typed(store, params, "decision")
122
+
123
+
124
+ def handle_store_fact(store: MemoryStore, params: dict) -> str:
125
+ return _handle_store_typed(store, params, "fact")
126
+
127
+
128
+ def handle_store_learning(store: MemoryStore, params: dict) -> str:
129
+ return _handle_store_typed(store, params, "learning")
130
+
131
+
132
+ def handle_store_convention(store: MemoryStore, params: dict) -> str:
133
+ return _handle_store_typed(store, params, "convention")
134
+
135
+
136
+ def handle_store_profile(store: MemoryStore, params: dict) -> str:
137
+ project = _require(params, "project")
138
+ content = _require(params, "content")
139
+ tags = params.get("tags") or []
140
+ entry = store.upsert_profile(project=project, content=content, tags=tags)
141
+ return json.dumps(entry)
142
+
143
+
144
+ def handle_export_memories(store: MemoryStore, params: dict) -> str:
145
+ project = _require(params, "project")
146
+ entry_type = params.get("entry_type") or None
147
+ results = store.export_entries(project=project, entry_type=entry_type)
148
+ return json.dumps(results)
149
+
150
+
151
+ def handle_get_profile(store: MemoryStore, params: dict) -> str:
152
+ project = _require(params, "project")
153
+ entry_type = params.get("entry_type") or None
154
+ results = store.get_profile(project=project, entry_type=entry_type)
155
+ return json.dumps(results)
156
+
157
+
158
+ def handle_ping(store: MemoryStore, params: dict) -> str:
159
+ return json.dumps(
160
+ {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
161
+ )
162
+
163
+
164
+ def handle_store_source(store: MemoryStore, params: dict) -> str:
165
+ url = _require(params, "url")
166
+ title = _require(params, "title")
167
+ description = _require(params, "description")
168
+ source_kind = _require(params, "source_kind")
169
+ tags = params.get("tags") or []
170
+ content = params.get("content") or None
171
+ supersedes = params.get("supersedes") or None
172
+ result = store.store_source(
173
+ url=url,
174
+ title=title,
175
+ description=description,
176
+ source_kind=source_kind,
177
+ tags=tags,
178
+ content=content,
179
+ supersedes=supersedes,
180
+ )
181
+ return json.dumps(result)
182
+
183
+
184
+ # ── Tool definitions (shared between list_tools and the wire-up) ──────────
185
+
186
+
187
+ def _tool_definitions() -> list[Tool]:
188
+ """Static tool definitions for the memory-server MCP surface."""
189
+ valid_types = sorted(VALID_TYPES)
190
+ return [
191
+ Tool(
192
+ name="search_memory",
193
+ description="Search memory entries across projects. Omit project to search all projects.",
194
+ inputSchema={
195
+ "type": "object",
196
+ "properties": {
197
+ "project": {"type": "string"},
198
+ "entry_type": {"type": "string", "enum": valid_types},
199
+ "tags": {"type": "array", "items": {"type": "string"}},
200
+ "query": {"type": "string"},
201
+ },
202
+ },
203
+ ),
204
+ Tool(
205
+ name="store_decision",
206
+ description="Store a decision entry",
207
+ inputSchema={
208
+ "type": "object",
209
+ "required": ["project", "content"],
210
+ "properties": {
211
+ "project": {"type": "string"},
212
+ "content": {"type": "string"},
213
+ "description": {"type": "string"},
214
+ "tags": {"type": "array", "items": {"type": "string"}},
215
+ "openspec_change_id": {"type": "string"},
216
+ },
217
+ },
218
+ ),
219
+ Tool(
220
+ name="store_fact",
221
+ description="Store a fact entry",
222
+ inputSchema={
223
+ "type": "object",
224
+ "required": ["project", "content"],
225
+ "properties": {
226
+ "project": {"type": "string"},
227
+ "content": {"type": "string"},
228
+ "description": {"type": "string"},
229
+ "tags": {"type": "array", "items": {"type": "string"}},
230
+ "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
231
+ },
232
+ },
233
+ ),
234
+ Tool(
235
+ name="store_learning",
236
+ description="Store a learning entry",
237
+ inputSchema={
238
+ "type": "object",
239
+ "required": ["project", "content"],
240
+ "properties": {
241
+ "project": {"type": "string"},
242
+ "content": {"type": "string"},
243
+ "description": {"type": "string"},
244
+ "tags": {"type": "array", "items": {"type": "string"}},
245
+ },
246
+ },
247
+ ),
248
+ Tool(
249
+ name="store_convention",
250
+ description="Store a convention entry",
251
+ inputSchema={
252
+ "type": "object",
253
+ "required": ["project", "content"],
254
+ "properties": {
255
+ "project": {"type": "string"},
256
+ "content": {"type": "string"},
257
+ "description": {"type": "string"},
258
+ "tags": {"type": "array", "items": {"type": "string"}},
259
+ },
260
+ },
261
+ ),
262
+ Tool(
263
+ name="store_profile",
264
+ description="Store or update a user profile entry for a project",
265
+ inputSchema={
266
+ "type": "object",
267
+ "required": ["project", "content"],
268
+ "properties": {
269
+ "project": {"type": "string"},
270
+ "content": {"type": "string"},
271
+ "tags": {"type": "array", "items": {"type": "string"}},
272
+ },
273
+ },
274
+ ),
275
+ Tool(
276
+ name="store_source",
277
+ description="Store a source reference (article, transcript, PDF, video, link)",
278
+ inputSchema={
279
+ "type": "object",
280
+ "required": ["url", "title", "description", "source_kind"],
281
+ "properties": {
282
+ "url": {"type": "string"},
283
+ "title": {"type": "string"},
284
+ "description": {"type": "string"},
285
+ "source_kind": {
286
+ "type": "string",
287
+ "enum": ["article", "transcript", "pdf", "video", "link", "other"],
288
+ },
289
+ "tags": {"type": "array", "items": {"type": "string"}},
290
+ "content": {"type": "string"},
291
+ "supersedes": {"type": "string"},
292
+ },
293
+ },
294
+ ),
295
+ Tool(
296
+ name="export_memories",
297
+ description="Export all memory entries for a project (no limit)",
298
+ inputSchema={
299
+ "type": "object",
300
+ "required": ["project"],
301
+ "properties": {
302
+ "project": {"type": "string"},
303
+ "entry_type": {"type": "string", "enum": valid_types},
304
+ },
305
+ },
306
+ ),
307
+ Tool(
308
+ name="get_profile",
309
+ description="Retrieve the global tech profile for a project",
310
+ inputSchema={
311
+ "type": "object",
312
+ "required": ["project"],
313
+ "properties": {
314
+ "project": {"type": "string"},
315
+ "entry_type": {"type": "string", "enum": valid_types},
316
+ },
317
+ },
318
+ ),
319
+ Tool(
320
+ name="ping",
321
+ description="Health check — returns ok and current timestamp",
322
+ inputSchema={"type": "object", "properties": {}},
323
+ ),
324
+ ]
325
+
326
+
327
+ # ── MCP server factory (mcp 1.26.0 keyword-arg API) ───────────────────────
328
+
329
+
330
+ def create_app(store: MemoryStore) -> Server:
331
+ """Create and return a configured MCP server instance.
332
+
333
+ Uses the keyword-argument API of `mcp.server.Server` (>= 1.0). The handlers
334
+ run synchronously (the underlying MCP layer awaits them in an executor).
335
+ """
336
+ tool_defs = _tool_definitions()
337
+ handlers = {
338
+ "search_memory": handle_search_memory,
339
+ "store_decision": handle_store_decision,
340
+ "store_fact": handle_store_fact,
341
+ "store_learning": handle_store_learning,
342
+ "store_convention": handle_store_convention,
343
+ "store_profile": handle_store_profile,
344
+ "store_source": handle_store_source,
345
+ "export_memories": handle_export_memories,
346
+ "get_profile": handle_get_profile,
347
+ "ping": handle_ping,
348
+ }
349
+
350
+ async def on_list_tools(
351
+ ctx: ServerRequestContext, params: PaginatedRequestParams | None
352
+ ) -> ListToolsResult:
353
+ return ListToolsResult(tools=tool_defs)
354
+
355
+ async def on_call_tool(
356
+ ctx: ServerRequestContext, params: CallToolRequestParams
357
+ ) -> CallToolResult:
358
+ name = params.name
359
+ arguments: dict[str, Any] = dict(params.arguments or {})
360
+ if name not in handlers:
361
+ raise ValueError(f"Unknown tool: {name}")
362
+ text = handlers[name](store, arguments)
363
+ _log_tool_call(name, arguments)
364
+ return CallToolResult(
365
+ content=[TextContent(type="text", text=text)],
366
+ isError=False,
367
+ )
368
+
369
+ return Server(
370
+ "memory-server",
371
+ on_list_tools=on_list_tools,
372
+ on_call_tool=on_call_tool,
373
+ )
374
+
375
+
376
+ # ── Entry point for `memory-server` console script ──────────────────────────
377
+
378
+
379
+ async def run() -> None:
380
+ """Validate storage, build store, start stdio MCP server."""
381
+ from cli import get_memory_path, validate_storage_path
382
+
383
+ memory_path = get_memory_path()
384
+ validate_storage_path(str(memory_path))
385
+ store = MemoryStore(storage_path=memory_path)
386
+ store.initialize()
387
+ app = create_app(store)
388
+ async with stdio_server() as (r, w):
389
+ await app.run(r, w, app.create_initialization_options())
390
+
391
+
392
+ def main() -> None:
393
+ import asyncio
394
+
395
+ asyncio.run(run())
396
+
397
+
398
+ if __name__ == "__main__":
399
+ main()