ragtime-cli 0.1.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.

Potentially problematic release.


This version of ragtime-cli might be problematic. Click here for more details.

src/mcp_server.py ADDED
@@ -0,0 +1,590 @@
1
+ """
2
+ MCP Server for ragtime.
3
+
4
+ Exposes ragtime operations as MCP tools for Claude integration.
5
+ Run with: python -m src.mcp_server
6
+ """
7
+
8
+ import sys
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .db import RagtimeDB
15
+ from .memory import Memory, MemoryStore
16
+
17
+
18
+ class RagtimeMCPServer:
19
+ """MCP Server that exposes ragtime operations as tools."""
20
+
21
+ def __init__(self, project_path: Path | None = None):
22
+ """
23
+ Initialize the MCP server.
24
+
25
+ Args:
26
+ project_path: Root of the project (defaults to cwd)
27
+ """
28
+ self.project_path = project_path or Path.cwd()
29
+ self._db = None
30
+ self._store = None
31
+
32
+ @property
33
+ def db(self) -> RagtimeDB:
34
+ """Lazy-load the database."""
35
+ if self._db is None:
36
+ db_path = self.project_path / ".ragtime" / "index"
37
+ self._db = RagtimeDB(db_path)
38
+ return self._db
39
+
40
+ @property
41
+ def store(self) -> MemoryStore:
42
+ """Lazy-load the memory store."""
43
+ if self._store is None:
44
+ self._store = MemoryStore(self.project_path, self.db)
45
+ return self._store
46
+
47
+ def get_author(self) -> str:
48
+ """Get the current developer's username."""
49
+ try:
50
+ result = subprocess.run(
51
+ ["gh", "api", "user", "--jq", ".login"],
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=5,
55
+ )
56
+ if result.returncode == 0 and result.stdout.strip():
57
+ return result.stdout.strip()
58
+ except (subprocess.TimeoutExpired, FileNotFoundError):
59
+ pass
60
+
61
+ try:
62
+ result = subprocess.run(
63
+ ["git", "config", "user.name"],
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=5,
67
+ )
68
+ if result.returncode == 0 and result.stdout.strip():
69
+ return result.stdout.strip().lower().replace(" ", "-")
70
+ except (subprocess.TimeoutExpired, FileNotFoundError):
71
+ pass
72
+
73
+ return "unknown"
74
+
75
+ def get_tools(self) -> list[dict]:
76
+ """Return the list of available tools."""
77
+ return [
78
+ {
79
+ "name": "remember",
80
+ "description": "Store a memory with structured metadata",
81
+ "inputSchema": {
82
+ "type": "object",
83
+ "properties": {
84
+ "content": {
85
+ "type": "string",
86
+ "description": "The memory content to store"
87
+ },
88
+ "namespace": {
89
+ "type": "string",
90
+ "description": "Namespace: app, team, user-{name}, branch-{name}"
91
+ },
92
+ "type": {
93
+ "type": "string",
94
+ "enum": ["architecture", "feature", "integration", "convention",
95
+ "preference", "decision", "pattern", "task-state", "handoff"],
96
+ "description": "Memory type"
97
+ },
98
+ "component": {
99
+ "type": "string",
100
+ "description": "Component area (e.g., auth, claims, shifts)"
101
+ },
102
+ "confidence": {
103
+ "type": "string",
104
+ "enum": ["high", "medium", "low"],
105
+ "default": "medium",
106
+ "description": "Confidence level"
107
+ },
108
+ "confidence_reason": {
109
+ "type": "string",
110
+ "description": "Why this confidence level"
111
+ },
112
+ "source": {
113
+ "type": "string",
114
+ "default": "remember",
115
+ "description": "Source of this memory"
116
+ },
117
+ "issue": {
118
+ "type": "string",
119
+ "description": "Related GitHub issue (e.g., #301)"
120
+ },
121
+ "epic": {
122
+ "type": "string",
123
+ "description": "Parent epic (e.g., #286)"
124
+ },
125
+ "branch": {
126
+ "type": "string",
127
+ "description": "Related branch name"
128
+ }
129
+ },
130
+ "required": ["content", "namespace", "type"]
131
+ }
132
+ },
133
+ {
134
+ "name": "search",
135
+ "description": "Semantic search over indexed content and memories",
136
+ "inputSchema": {
137
+ "type": "object",
138
+ "properties": {
139
+ "query": {
140
+ "type": "string",
141
+ "description": "Natural language search query"
142
+ },
143
+ "namespace": {
144
+ "type": "string",
145
+ "description": "Filter by namespace"
146
+ },
147
+ "type": {
148
+ "type": "string",
149
+ "description": "Filter by type (docs, code, architecture, etc.)"
150
+ },
151
+ "component": {
152
+ "type": "string",
153
+ "description": "Filter by component"
154
+ },
155
+ "limit": {
156
+ "type": "integer",
157
+ "default": 10,
158
+ "description": "Max results to return"
159
+ }
160
+ },
161
+ "required": ["query"]
162
+ }
163
+ },
164
+ {
165
+ "name": "store_doc",
166
+ "description": "Store a document verbatim (like handoff.md)",
167
+ "inputSchema": {
168
+ "type": "object",
169
+ "properties": {
170
+ "content": {
171
+ "type": "string",
172
+ "description": "The document content to store"
173
+ },
174
+ "namespace": {
175
+ "type": "string",
176
+ "description": "Namespace for the document"
177
+ },
178
+ "doc_type": {
179
+ "type": "string",
180
+ "enum": ["handoff", "document", "plan", "notes"],
181
+ "default": "handoff",
182
+ "description": "Document type"
183
+ }
184
+ },
185
+ "required": ["content", "namespace"]
186
+ }
187
+ },
188
+ {
189
+ "name": "list_memories",
190
+ "description": "List memories with optional filters",
191
+ "inputSchema": {
192
+ "type": "object",
193
+ "properties": {
194
+ "namespace": {
195
+ "type": "string",
196
+ "description": "Filter by namespace (use * suffix for prefix match)"
197
+ },
198
+ "type": {
199
+ "type": "string",
200
+ "description": "Filter by type"
201
+ },
202
+ "status": {
203
+ "type": "string",
204
+ "enum": ["active", "graduated", "abandoned", "ungraduated"],
205
+ "description": "Filter by status"
206
+ },
207
+ "component": {
208
+ "type": "string",
209
+ "description": "Filter by component"
210
+ },
211
+ "limit": {
212
+ "type": "integer",
213
+ "default": 20,
214
+ "description": "Max results"
215
+ }
216
+ }
217
+ }
218
+ },
219
+ {
220
+ "name": "get_memory",
221
+ "description": "Get a specific memory by ID",
222
+ "inputSchema": {
223
+ "type": "object",
224
+ "properties": {
225
+ "memory_id": {
226
+ "type": "string",
227
+ "description": "The memory ID"
228
+ }
229
+ },
230
+ "required": ["memory_id"]
231
+ }
232
+ },
233
+ {
234
+ "name": "forget",
235
+ "description": "Delete a memory by ID",
236
+ "inputSchema": {
237
+ "type": "object",
238
+ "properties": {
239
+ "memory_id": {
240
+ "type": "string",
241
+ "description": "The memory ID to delete"
242
+ }
243
+ },
244
+ "required": ["memory_id"]
245
+ }
246
+ },
247
+ {
248
+ "name": "graduate",
249
+ "description": "Graduate a branch memory to app namespace",
250
+ "inputSchema": {
251
+ "type": "object",
252
+ "properties": {
253
+ "memory_id": {
254
+ "type": "string",
255
+ "description": "The memory ID to graduate"
256
+ },
257
+ "confidence": {
258
+ "type": "string",
259
+ "enum": ["high", "medium", "low"],
260
+ "default": "high",
261
+ "description": "Confidence level for graduated memory"
262
+ }
263
+ },
264
+ "required": ["memory_id"]
265
+ }
266
+ },
267
+ {
268
+ "name": "update_status",
269
+ "description": "Update a memory's status (e.g., mark as abandoned)",
270
+ "inputSchema": {
271
+ "type": "object",
272
+ "properties": {
273
+ "memory_id": {
274
+ "type": "string",
275
+ "description": "The memory ID"
276
+ },
277
+ "status": {
278
+ "type": "string",
279
+ "enum": ["active", "graduated", "abandoned", "ungraduated"],
280
+ "description": "New status"
281
+ }
282
+ },
283
+ "required": ["memory_id", "status"]
284
+ }
285
+ }
286
+ ]
287
+
288
+ def call_tool(self, name: str, arguments: dict) -> Any:
289
+ """Execute a tool call."""
290
+ if name == "remember":
291
+ return self._remember(arguments)
292
+ elif name == "search":
293
+ return self._search(arguments)
294
+ elif name == "store_doc":
295
+ return self._store_doc(arguments)
296
+ elif name == "list_memories":
297
+ return self._list_memories(arguments)
298
+ elif name == "get_memory":
299
+ return self._get_memory(arguments)
300
+ elif name == "forget":
301
+ return self._forget(arguments)
302
+ elif name == "graduate":
303
+ return self._graduate(arguments)
304
+ elif name == "update_status":
305
+ return self._update_status(arguments)
306
+ else:
307
+ raise ValueError(f"Unknown tool: {name}")
308
+
309
+ def _remember(self, args: dict) -> dict:
310
+ """Store a memory."""
311
+ memory = Memory(
312
+ content=args["content"],
313
+ namespace=args["namespace"],
314
+ type=args["type"],
315
+ component=args.get("component"),
316
+ confidence=args.get("confidence", "medium"),
317
+ confidence_reason=args.get("confidence_reason"),
318
+ source=args.get("source", "remember"),
319
+ author=self.get_author(),
320
+ issue=args.get("issue"),
321
+ epic=args.get("epic"),
322
+ branch=args.get("branch"),
323
+ )
324
+
325
+ file_path = self.store.save(memory)
326
+
327
+ return {
328
+ "success": True,
329
+ "memory_id": memory.id,
330
+ "file": str(file_path.relative_to(self.project_path)),
331
+ "namespace": memory.namespace,
332
+ "type": memory.type,
333
+ }
334
+
335
+ def _search(self, args: dict) -> dict:
336
+ """Search indexed content."""
337
+ results = self.db.search(
338
+ query=args["query"],
339
+ limit=args.get("limit", 10),
340
+ namespace=args.get("namespace"),
341
+ type_filter=args.get("type"),
342
+ component=args.get("component"),
343
+ )
344
+
345
+ return {
346
+ "count": len(results),
347
+ "results": [
348
+ {
349
+ "content": r["content"],
350
+ "metadata": r["metadata"],
351
+ "score": 1 - r["distance"] if r["distance"] else None,
352
+ }
353
+ for r in results
354
+ ]
355
+ }
356
+
357
+ def _store_doc(self, args: dict) -> dict:
358
+ """Store a document verbatim."""
359
+ memory = Memory(
360
+ content=args["content"],
361
+ namespace=args["namespace"],
362
+ type=args.get("doc_type", "handoff"),
363
+ source="store-doc",
364
+ confidence="medium",
365
+ confidence_reason="document",
366
+ author=self.get_author(),
367
+ )
368
+
369
+ file_path = self.store.save(memory)
370
+
371
+ return {
372
+ "success": True,
373
+ "memory_id": memory.id,
374
+ "file": str(file_path.relative_to(self.project_path)),
375
+ "namespace": memory.namespace,
376
+ "type": memory.type,
377
+ }
378
+
379
+ def _list_memories(self, args: dict) -> dict:
380
+ """List memories with filters."""
381
+ memories = self.store.list_memories(
382
+ namespace=args.get("namespace"),
383
+ type_filter=args.get("type"),
384
+ status=args.get("status"),
385
+ component=args.get("component"),
386
+ limit=args.get("limit", 20),
387
+ )
388
+
389
+ return {
390
+ "count": len(memories),
391
+ "memories": [
392
+ {
393
+ "id": m.id,
394
+ "namespace": m.namespace,
395
+ "type": m.type,
396
+ "component": m.component,
397
+ "status": m.status,
398
+ "confidence": m.confidence,
399
+ "added": m.added,
400
+ "source": m.source,
401
+ "preview": m.content[:100],
402
+ }
403
+ for m in memories
404
+ ]
405
+ }
406
+
407
+ def _get_memory(self, args: dict) -> dict:
408
+ """Get a specific memory."""
409
+ memory = self.store.get(args["memory_id"])
410
+
411
+ if not memory:
412
+ return {"success": False, "error": "Memory not found"}
413
+
414
+ return {
415
+ "success": True,
416
+ "memory": {
417
+ "id": memory.id,
418
+ "content": memory.content,
419
+ "namespace": memory.namespace,
420
+ "type": memory.type,
421
+ "component": memory.component,
422
+ "status": memory.status,
423
+ "confidence": memory.confidence,
424
+ "confidence_reason": memory.confidence_reason,
425
+ "added": memory.added,
426
+ "author": memory.author,
427
+ "source": memory.source,
428
+ "issue": memory.issue,
429
+ "epic": memory.epic,
430
+ "branch": memory.branch,
431
+ }
432
+ }
433
+
434
+ def _forget(self, args: dict) -> dict:
435
+ """Delete a memory."""
436
+ success = self.store.delete(args["memory_id"])
437
+
438
+ return {
439
+ "success": success,
440
+ "memory_id": args["memory_id"],
441
+ }
442
+
443
+ def _graduate(self, args: dict) -> dict:
444
+ """Graduate a branch memory."""
445
+ try:
446
+ graduated = self.store.graduate(
447
+ args["memory_id"],
448
+ args.get("confidence", "high"),
449
+ )
450
+
451
+ if not graduated:
452
+ return {"success": False, "error": "Memory not found"}
453
+
454
+ return {
455
+ "success": True,
456
+ "original_id": args["memory_id"],
457
+ "graduated_id": graduated.id,
458
+ "namespace": graduated.namespace,
459
+ }
460
+ except ValueError as e:
461
+ return {"success": False, "error": str(e)}
462
+
463
+ def _update_status(self, args: dict) -> dict:
464
+ """Update a memory's status."""
465
+ success = self.store.update_status(
466
+ args["memory_id"],
467
+ args["status"],
468
+ )
469
+
470
+ return {
471
+ "success": success,
472
+ "memory_id": args["memory_id"],
473
+ "status": args["status"],
474
+ }
475
+
476
+ def handle_message(self, message: dict) -> dict:
477
+ """Handle an incoming JSON-RPC message."""
478
+ method = message.get("method")
479
+ msg_id = message.get("id")
480
+
481
+ try:
482
+ if method == "initialize":
483
+ return {
484
+ "jsonrpc": "2.0",
485
+ "id": msg_id,
486
+ "result": {
487
+ "protocolVersion": "2024-11-05",
488
+ "serverInfo": {
489
+ "name": "ragtime",
490
+ "version": "0.1.0",
491
+ },
492
+ "capabilities": {
493
+ "tools": {},
494
+ },
495
+ },
496
+ }
497
+
498
+ elif method == "notifications/initialized":
499
+ # No response needed for notifications
500
+ return None
501
+
502
+ elif method == "tools/list":
503
+ return {
504
+ "jsonrpc": "2.0",
505
+ "id": msg_id,
506
+ "result": {
507
+ "tools": self.get_tools(),
508
+ },
509
+ }
510
+
511
+ elif method == "tools/call":
512
+ params = message.get("params", {})
513
+ tool_name = params.get("name")
514
+ arguments = params.get("arguments", {})
515
+
516
+ result = self.call_tool(tool_name, arguments)
517
+
518
+ return {
519
+ "jsonrpc": "2.0",
520
+ "id": msg_id,
521
+ "result": {
522
+ "content": [
523
+ {
524
+ "type": "text",
525
+ "text": json.dumps(result, indent=2),
526
+ }
527
+ ],
528
+ },
529
+ }
530
+
531
+ else:
532
+ return {
533
+ "jsonrpc": "2.0",
534
+ "id": msg_id,
535
+ "error": {
536
+ "code": -32601,
537
+ "message": f"Method not found: {method}",
538
+ },
539
+ }
540
+
541
+ except Exception as e:
542
+ return {
543
+ "jsonrpc": "2.0",
544
+ "id": msg_id,
545
+ "error": {
546
+ "code": -32603,
547
+ "message": str(e),
548
+ },
549
+ }
550
+
551
+ def run(self):
552
+ """Run the MCP server on stdin/stdout."""
553
+ while True:
554
+ try:
555
+ line = sys.stdin.readline()
556
+ if not line:
557
+ break
558
+
559
+ message = json.loads(line)
560
+ response = self.handle_message(message)
561
+
562
+ if response is not None:
563
+ sys.stdout.write(json.dumps(response) + "\n")
564
+ sys.stdout.flush()
565
+
566
+ except json.JSONDecodeError:
567
+ continue
568
+ except KeyboardInterrupt:
569
+ break
570
+
571
+
572
+ def main():
573
+ """Entry point for the MCP server."""
574
+ import argparse
575
+
576
+ parser = argparse.ArgumentParser(description="Ragtime MCP Server")
577
+ parser.add_argument(
578
+ "--path",
579
+ type=Path,
580
+ default=Path.cwd(),
581
+ help="Project path (defaults to current directory)",
582
+ )
583
+ args = parser.parse_args()
584
+
585
+ server = RagtimeMCPServer(args.path)
586
+ server.run()
587
+
588
+
589
+ if __name__ == "__main__":
590
+ main()