alma-memory 0.2.0__py3-none-any.whl

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.
alma/mcp/server.py ADDED
@@ -0,0 +1,533 @@
1
+ """
2
+ ALMA MCP Server Implementation.
3
+
4
+ Provides the main server class that handles MCP protocol communication.
5
+ Supports both stdio (for Claude Code) and HTTP modes.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import sys
12
+ from typing import Dict, Any, Optional, List, Callable
13
+ from datetime import datetime, timezone
14
+
15
+ from alma import ALMA
16
+ from alma.mcp.tools import (
17
+ alma_retrieve,
18
+ alma_learn,
19
+ alma_add_preference,
20
+ alma_add_knowledge,
21
+ alma_forget,
22
+ alma_stats,
23
+ alma_health,
24
+ )
25
+ from alma.mcp.resources import (
26
+ get_config_resource,
27
+ get_agents_resource,
28
+ list_resources,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class ALMAMCPServer:
35
+ """
36
+ MCP Server for ALMA.
37
+
38
+ Exposes ALMA functionality via the Model Context Protocol,
39
+ allowing any MCP-compatible client (like Claude Code) to
40
+ interact with the memory system.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ alma: ALMA,
46
+ server_name: str = "alma-memory",
47
+ server_version: str = "0.2.0",
48
+ ):
49
+ """
50
+ Initialize the MCP server.
51
+
52
+ Args:
53
+ alma: Configured ALMA instance
54
+ server_name: Server identifier
55
+ server_version: Server version
56
+ """
57
+ self.alma = alma
58
+ self.server_name = server_name
59
+ self.server_version = server_version
60
+
61
+ # Register tools
62
+ self.tools = self._register_tools()
63
+
64
+ # Register resources
65
+ self.resources = list_resources()
66
+
67
+ def _register_tools(self) -> List[Dict[str, Any]]:
68
+ """Register available MCP tools."""
69
+ return [
70
+ {
71
+ "name": "alma_retrieve",
72
+ "description": "Retrieve relevant memories for a task. Returns heuristics, domain knowledge, anti-patterns, and user preferences.",
73
+ "inputSchema": {
74
+ "type": "object",
75
+ "properties": {
76
+ "task": {
77
+ "type": "string",
78
+ "description": "Description of the task to perform",
79
+ },
80
+ "agent": {
81
+ "type": "string",
82
+ "description": "Name of the agent requesting memories (e.g., 'helena', 'victor')",
83
+ },
84
+ "user_id": {
85
+ "type": "string",
86
+ "description": "Optional user ID for preference retrieval",
87
+ },
88
+ "top_k": {
89
+ "type": "integer",
90
+ "description": "Maximum items per memory type (default: 5)",
91
+ "default": 5,
92
+ },
93
+ },
94
+ "required": ["task", "agent"],
95
+ },
96
+ },
97
+ {
98
+ "name": "alma_learn",
99
+ "description": "Record a task outcome for learning. Use after completing a task to help improve future performance.",
100
+ "inputSchema": {
101
+ "type": "object",
102
+ "properties": {
103
+ "agent": {
104
+ "type": "string",
105
+ "description": "Name of the agent that executed the task",
106
+ },
107
+ "task": {
108
+ "type": "string",
109
+ "description": "Description of the task",
110
+ },
111
+ "outcome": {
112
+ "type": "string",
113
+ "enum": ["success", "failure"],
114
+ "description": "Whether the task succeeded or failed",
115
+ },
116
+ "strategy_used": {
117
+ "type": "string",
118
+ "description": "What approach was taken",
119
+ },
120
+ "task_type": {
121
+ "type": "string",
122
+ "description": "Category of task (for grouping)",
123
+ },
124
+ "duration_ms": {
125
+ "type": "integer",
126
+ "description": "How long the task took in milliseconds",
127
+ },
128
+ "error_message": {
129
+ "type": "string",
130
+ "description": "Error details if failed",
131
+ },
132
+ "feedback": {
133
+ "type": "string",
134
+ "description": "User feedback if provided",
135
+ },
136
+ },
137
+ "required": ["agent", "task", "outcome", "strategy_used"],
138
+ },
139
+ },
140
+ {
141
+ "name": "alma_add_preference",
142
+ "description": "Add a user preference to memory. Preferences persist across sessions.",
143
+ "inputSchema": {
144
+ "type": "object",
145
+ "properties": {
146
+ "user_id": {
147
+ "type": "string",
148
+ "description": "User identifier",
149
+ },
150
+ "category": {
151
+ "type": "string",
152
+ "description": "Category (communication, code_style, workflow)",
153
+ },
154
+ "preference": {
155
+ "type": "string",
156
+ "description": "The preference text",
157
+ },
158
+ "source": {
159
+ "type": "string",
160
+ "description": "How this was learned (default: explicit_instruction)",
161
+ "default": "explicit_instruction",
162
+ },
163
+ },
164
+ "required": ["user_id", "category", "preference"],
165
+ },
166
+ },
167
+ {
168
+ "name": "alma_add_knowledge",
169
+ "description": "Add domain knowledge within agent's scope. Knowledge is facts, not strategies.",
170
+ "inputSchema": {
171
+ "type": "object",
172
+ "properties": {
173
+ "agent": {
174
+ "type": "string",
175
+ "description": "Agent this knowledge belongs to",
176
+ },
177
+ "domain": {
178
+ "type": "string",
179
+ "description": "Knowledge domain",
180
+ },
181
+ "fact": {
182
+ "type": "string",
183
+ "description": "The fact to remember",
184
+ },
185
+ "source": {
186
+ "type": "string",
187
+ "description": "How this was learned (default: user_stated)",
188
+ "default": "user_stated",
189
+ },
190
+ },
191
+ "required": ["agent", "domain", "fact"],
192
+ },
193
+ },
194
+ {
195
+ "name": "alma_forget",
196
+ "description": "Prune stale or low-confidence memories to keep the system clean.",
197
+ "inputSchema": {
198
+ "type": "object",
199
+ "properties": {
200
+ "agent": {
201
+ "type": "string",
202
+ "description": "Specific agent to prune, or omit for all",
203
+ },
204
+ "older_than_days": {
205
+ "type": "integer",
206
+ "description": "Remove outcomes older than this (default: 90)",
207
+ "default": 90,
208
+ },
209
+ "below_confidence": {
210
+ "type": "number",
211
+ "description": "Remove heuristics below this confidence (default: 0.3)",
212
+ "default": 0.3,
213
+ },
214
+ },
215
+ },
216
+ },
217
+ {
218
+ "name": "alma_stats",
219
+ "description": "Get memory statistics for monitoring and debugging.",
220
+ "inputSchema": {
221
+ "type": "object",
222
+ "properties": {
223
+ "agent": {
224
+ "type": "string",
225
+ "description": "Specific agent or omit for all",
226
+ },
227
+ },
228
+ },
229
+ },
230
+ {
231
+ "name": "alma_health",
232
+ "description": "Health check for the ALMA server.",
233
+ "inputSchema": {
234
+ "type": "object",
235
+ "properties": {},
236
+ },
237
+ },
238
+ ]
239
+
240
+ async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
241
+ """
242
+ Handle an incoming MCP request.
243
+
244
+ Args:
245
+ request: The MCP request
246
+
247
+ Returns:
248
+ MCP response
249
+ """
250
+ method = request.get("method", "")
251
+ params = request.get("params", {})
252
+ request_id = request.get("id")
253
+
254
+ try:
255
+ if method == "initialize":
256
+ return self._handle_initialize(request_id, params)
257
+ elif method == "tools/list":
258
+ return self._handle_tools_list(request_id)
259
+ elif method == "tools/call":
260
+ return await self._handle_tool_call(request_id, params)
261
+ elif method == "resources/list":
262
+ return self._handle_resources_list(request_id)
263
+ elif method == "resources/read":
264
+ return self._handle_resource_read(request_id, params)
265
+ elif method == "ping":
266
+ return self._success_response(request_id, {})
267
+ else:
268
+ return self._error_response(
269
+ request_id,
270
+ -32601,
271
+ f"Method not found: {method}",
272
+ )
273
+
274
+ except Exception as e:
275
+ logger.exception(f"Error handling request: {e}")
276
+ return self._error_response(request_id, -32603, str(e))
277
+
278
+ def _handle_initialize(
279
+ self,
280
+ request_id: Optional[int],
281
+ params: Dict[str, Any],
282
+ ) -> Dict[str, Any]:
283
+ """Handle initialize request."""
284
+ return self._success_response(request_id, {
285
+ "protocolVersion": "2024-11-05",
286
+ "serverInfo": {
287
+ "name": self.server_name,
288
+ "version": self.server_version,
289
+ },
290
+ "capabilities": {
291
+ "tools": {},
292
+ "resources": {},
293
+ },
294
+ })
295
+
296
+ def _handle_tools_list(self, request_id: Optional[int]) -> Dict[str, Any]:
297
+ """Handle tools/list request."""
298
+ return self._success_response(request_id, {"tools": self.tools})
299
+
300
+ async def _handle_tool_call(
301
+ self,
302
+ request_id: Optional[int],
303
+ params: Dict[str, Any],
304
+ ) -> Dict[str, Any]:
305
+ """Handle tools/call request."""
306
+ tool_name = params.get("name", "")
307
+ arguments = params.get("arguments", {})
308
+
309
+ # Map tool names to functions
310
+ tool_handlers = {
311
+ "alma_retrieve": lambda: alma_retrieve(
312
+ self.alma,
313
+ task=arguments.get("task", ""),
314
+ agent=arguments.get("agent", ""),
315
+ user_id=arguments.get("user_id"),
316
+ top_k=arguments.get("top_k", 5),
317
+ ),
318
+ "alma_learn": lambda: alma_learn(
319
+ self.alma,
320
+ agent=arguments.get("agent", ""),
321
+ task=arguments.get("task", ""),
322
+ outcome=arguments.get("outcome", ""),
323
+ strategy_used=arguments.get("strategy_used", ""),
324
+ task_type=arguments.get("task_type"),
325
+ duration_ms=arguments.get("duration_ms"),
326
+ error_message=arguments.get("error_message"),
327
+ feedback=arguments.get("feedback"),
328
+ ),
329
+ "alma_add_preference": lambda: alma_add_preference(
330
+ self.alma,
331
+ user_id=arguments.get("user_id", ""),
332
+ category=arguments.get("category", ""),
333
+ preference=arguments.get("preference", ""),
334
+ source=arguments.get("source", "explicit_instruction"),
335
+ ),
336
+ "alma_add_knowledge": lambda: alma_add_knowledge(
337
+ self.alma,
338
+ agent=arguments.get("agent", ""),
339
+ domain=arguments.get("domain", ""),
340
+ fact=arguments.get("fact", ""),
341
+ source=arguments.get("source", "user_stated"),
342
+ ),
343
+ "alma_forget": lambda: alma_forget(
344
+ self.alma,
345
+ agent=arguments.get("agent"),
346
+ older_than_days=arguments.get("older_than_days", 90),
347
+ below_confidence=arguments.get("below_confidence", 0.3),
348
+ ),
349
+ "alma_stats": lambda: alma_stats(
350
+ self.alma,
351
+ agent=arguments.get("agent"),
352
+ ),
353
+ "alma_health": lambda: alma_health(self.alma),
354
+ }
355
+
356
+ if tool_name not in tool_handlers:
357
+ return self._error_response(
358
+ request_id,
359
+ -32602,
360
+ f"Unknown tool: {tool_name}",
361
+ )
362
+
363
+ result = tool_handlers[tool_name]()
364
+
365
+ return self._success_response(request_id, {
366
+ "content": [
367
+ {
368
+ "type": "text",
369
+ "text": json.dumps(result, indent=2),
370
+ }
371
+ ],
372
+ })
373
+
374
+ def _handle_resources_list(
375
+ self,
376
+ request_id: Optional[int],
377
+ ) -> Dict[str, Any]:
378
+ """Handle resources/list request."""
379
+ return self._success_response(request_id, {"resources": self.resources})
380
+
381
+ def _handle_resource_read(
382
+ self,
383
+ request_id: Optional[int],
384
+ params: Dict[str, Any],
385
+ ) -> Dict[str, Any]:
386
+ """Handle resources/read request."""
387
+ uri = params.get("uri", "")
388
+
389
+ if uri == "alma://config":
390
+ resource = get_config_resource(self.alma)
391
+ elif uri == "alma://agents":
392
+ resource = get_agents_resource(self.alma)
393
+ else:
394
+ return self._error_response(
395
+ request_id,
396
+ -32602,
397
+ f"Unknown resource: {uri}",
398
+ )
399
+
400
+ return self._success_response(request_id, {
401
+ "contents": [
402
+ {
403
+ "uri": resource["uri"],
404
+ "mimeType": resource["mimeType"],
405
+ "text": json.dumps(resource["content"], indent=2),
406
+ }
407
+ ],
408
+ })
409
+
410
+ def _success_response(
411
+ self,
412
+ request_id: Optional[int],
413
+ result: Any,
414
+ ) -> Dict[str, Any]:
415
+ """Create a success response."""
416
+ return {
417
+ "jsonrpc": "2.0",
418
+ "id": request_id,
419
+ "result": result,
420
+ }
421
+
422
+ def _error_response(
423
+ self,
424
+ request_id: Optional[int],
425
+ code: int,
426
+ message: str,
427
+ ) -> Dict[str, Any]:
428
+ """Create an error response."""
429
+ return {
430
+ "jsonrpc": "2.0",
431
+ "id": request_id,
432
+ "error": {
433
+ "code": code,
434
+ "message": message,
435
+ },
436
+ }
437
+
438
+ async def run_stdio(self):
439
+ """Run the server in stdio mode for Claude Code integration."""
440
+ logger.info(f"Starting ALMA MCP Server (stdio mode)")
441
+
442
+ reader = asyncio.StreamReader()
443
+ protocol = asyncio.StreamReaderProtocol(reader)
444
+ await asyncio.get_event_loop().connect_read_pipe(
445
+ lambda: protocol, sys.stdin
446
+ )
447
+
448
+ writer_transport, writer_protocol = await asyncio.get_event_loop().connect_write_pipe(
449
+ asyncio.streams.FlowControlMixin, sys.stdout
450
+ )
451
+ writer = asyncio.StreamWriter(
452
+ writer_transport, writer_protocol, None, asyncio.get_event_loop()
453
+ )
454
+
455
+ while True:
456
+ try:
457
+ # Read Content-Length header
458
+ header_line = await reader.readline()
459
+ if not header_line:
460
+ break
461
+
462
+ header = header_line.decode().strip()
463
+ if not header.startswith("Content-Length:"):
464
+ continue
465
+
466
+ content_length = int(header.split(":")[1].strip())
467
+
468
+ # Read empty line
469
+ await reader.readline()
470
+
471
+ # Read content
472
+ content = await reader.read(content_length)
473
+ request = json.loads(content.decode())
474
+
475
+ # Handle request
476
+ response = await self.handle_request(request)
477
+
478
+ # Send response
479
+ response_str = json.dumps(response)
480
+ response_bytes = response_str.encode()
481
+ header_bytes = f"Content-Length: {len(response_bytes)}\r\n\r\n".encode()
482
+
483
+ writer.write(header_bytes + response_bytes)
484
+ await writer.drain()
485
+
486
+ except asyncio.CancelledError:
487
+ break
488
+ except Exception as e:
489
+ logger.exception(f"Error in stdio loop: {e}")
490
+
491
+ async def run_http(self, host: str = "0.0.0.0", port: int = 8765):
492
+ """
493
+ Run the server in HTTP mode for remote access.
494
+
495
+ Note: Requires aiohttp (optional dependency).
496
+ """
497
+ try:
498
+ from aiohttp import web
499
+ except ImportError:
500
+ logger.error("aiohttp required for HTTP mode. Install with: pip install aiohttp")
501
+ return
502
+
503
+ async def handle_post(request: web.Request) -> web.Response:
504
+ """Handle HTTP POST requests."""
505
+ try:
506
+ data = await request.json()
507
+ response = await self.handle_request(data)
508
+ return web.json_response(response)
509
+ except Exception as e:
510
+ return web.json_response(
511
+ {"error": str(e)},
512
+ status=500,
513
+ )
514
+
515
+ async def handle_health(request: web.Request) -> web.Response:
516
+ """Handle health check endpoint."""
517
+ result = alma_health(self.alma)
518
+ return web.json_response(result)
519
+
520
+ app = web.Application()
521
+ app.router.add_post("/", handle_post)
522
+ app.router.add_get("/health", handle_health)
523
+
524
+ runner = web.AppRunner(app)
525
+ await runner.setup()
526
+ site = web.TCPSite(runner, host, port)
527
+ await site.start()
528
+
529
+ logger.info(f"ALMA MCP Server running on http://{host}:{port}")
530
+
531
+ # Keep running
532
+ while True:
533
+ await asyncio.sleep(3600)