cortexdb-mcp 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.
cortexdb_mcp/server.py ADDED
@@ -0,0 +1,1085 @@
1
+ """CortexDB MCP Server.
2
+
3
+ Exposes the full CortexDB API (memory, episodes, entities, knowledge graph,
4
+ search, usage, export/import) as MCP tools, resources, and prompts so that any
5
+ MCP-compatible AI tool can interact with CortexDB's long-term memory system.
6
+
7
+ Supported clients: Claude Desktop, Cursor, Windsurf, VS Code Copilot, and any
8
+ tool that speaks the Model Context Protocol.
9
+
10
+ Usage::
11
+
12
+ cortexdb-mcp # stdio transport (default)
13
+ python -m cortexdb_mcp.server
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from typing import Any
21
+
22
+ import httpx
23
+ from mcp.server.fastmcp import FastMCP
24
+
25
+ from cortexdb_mcp.config import CortexMCPConfig
26
+ from cortexdb_mcp.insights import InsightsEngine
27
+
28
+ logger = logging.getLogger("cortexdb_mcp")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Server & config
32
+ # ---------------------------------------------------------------------------
33
+
34
+ mcp = FastMCP(
35
+ "cortexdb",
36
+ instructions=(
37
+ "CortexDB is a long-term memory layer for AI systems. "
38
+ "Use these tools to store, search, and manage memories. "
39
+ "Memories are stored as immutable events and indexed into a knowledge graph. "
40
+ "Use memory_store to save information, memory_search or get_context to retrieve it, "
41
+ "and entity tools to explore the knowledge graph."
42
+ ),
43
+ )
44
+ _config = CortexMCPConfig.from_env()
45
+
46
+
47
+ def _client() -> httpx.AsyncClient:
48
+ """Return a configured ``httpx.AsyncClient`` for CortexDB."""
49
+ headers: dict[str, str] = {"Content-Type": "application/json"}
50
+ if _config.api_key:
51
+ headers["Authorization"] = f"Bearer {_config.api_key}"
52
+ return httpx.AsyncClient(
53
+ base_url=_config.url,
54
+ headers=headers,
55
+ timeout=_config.timeout,
56
+ )
57
+
58
+
59
+ async def _request(
60
+ method: str,
61
+ path: str,
62
+ *,
63
+ json_body: dict[str, Any] | None = None,
64
+ params: dict[str, Any] | None = None,
65
+ extra_headers: dict[str, str] | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Execute an HTTP request against CortexDB and return parsed JSON."""
68
+ async with _client() as client:
69
+ try:
70
+ kwargs: dict[str, Any] = {}
71
+ if json_body is not None:
72
+ kwargs["json"] = json_body
73
+ if params is not None:
74
+ kwargs["params"] = params
75
+ if extra_headers is not None:
76
+ kwargs["headers"] = extra_headers
77
+ response = await client.request(method, path, **kwargs)
78
+ response.raise_for_status()
79
+ return response.json()
80
+ except httpx.HTTPStatusError as exc:
81
+ body = exc.response.text
82
+ raise RuntimeError(
83
+ f"CortexDB returned HTTP {exc.response.status_code}: {body}"
84
+ ) from exc
85
+ except httpx.RequestError as exc:
86
+ raise RuntimeError(
87
+ f"Failed to reach CortexDB at {_config.url}: {exc}"
88
+ ) from exc
89
+
90
+
91
+ # ===========================================================================
92
+ # TOOLS — Memory Operations
93
+ # ===========================================================================
94
+
95
+
96
+ @mcp.tool()
97
+ async def memory_store(
98
+ content: str,
99
+ source: str | None = None,
100
+ episode_type: str = "message",
101
+ tenant_id: str | None = None,
102
+ namespace: str | None = None,
103
+ tags: list[str] | None = None,
104
+ ttl_seconds: int | None = None,
105
+ ) -> str:
106
+ """Store a new memory or episode in CortexDB.
107
+
108
+ Parameters
109
+ ----------
110
+ content:
111
+ The textual content to remember (max 1 MB).
112
+ source:
113
+ Origin of the memory (e.g. "slack", "github", "cursor").
114
+ episode_type:
115
+ Type of episode: message, code_change, incident, deployment, issue,
116
+ document, review, meeting, alert, decision, custom. Default "message".
117
+ tenant_id:
118
+ Tenant scope for multi-tenant deployments.
119
+ namespace:
120
+ Logical namespace within the tenant.
121
+ tags:
122
+ Optional list of tags for categorisation.
123
+ ttl_seconds:
124
+ Auto-expiry time in seconds. Omit for permanent storage.
125
+ """
126
+ body: dict[str, Any] = {
127
+ "content": content,
128
+ "episode_type": episode_type,
129
+ }
130
+ if source is not None:
131
+ body["source"] = source
132
+ if tenant_id is not None:
133
+ body["tenant_id"] = tenant_id
134
+ if namespace is not None:
135
+ body["namespace"] = namespace
136
+ if tags is not None:
137
+ body["tags"] = tags
138
+ if ttl_seconds is not None:
139
+ body["ttl_seconds"] = ttl_seconds
140
+
141
+ path = "/v1/episodes" if source or tags else "/v1/remember"
142
+ result = await _request("POST", path, json_body=body)
143
+
144
+ event_id = result.get("event_id") or result.get("episode_id") or "unknown"
145
+ return f"Stored successfully. ID: {event_id}"
146
+
147
+
148
+ @mcp.tool()
149
+ async def memory_search(
150
+ query: str,
151
+ tenant_id: str | None = None,
152
+ max_results: int = 10,
153
+ ) -> str:
154
+ """Search CortexDB for relevant memories using hybrid retrieval (BM25 + vector + graph).
155
+
156
+ Parameters
157
+ ----------
158
+ query:
159
+ Natural-language search query.
160
+ tenant_id:
161
+ Tenant scope for multi-tenant deployments.
162
+ max_results:
163
+ Maximum number of results to return (default 10).
164
+ """
165
+ body: dict[str, Any] = {
166
+ "query": query,
167
+ "max_results": max_results,
168
+ }
169
+ if tenant_id is not None:
170
+ body["tenant_id"] = tenant_id
171
+
172
+ result = await _request("POST", "/v1/recall", json_body=body)
173
+
174
+ context = result.get("context", "")
175
+ confidence = result.get("confidence")
176
+ items = result.get("results", [])
177
+
178
+ parts: list[str] = []
179
+ if context:
180
+ parts.append(context)
181
+ if items:
182
+ parts.append(f"\n--- {len(items)} result(s) ---")
183
+ for item in items:
184
+ parts.append(json.dumps(item, indent=2))
185
+ if confidence is not None:
186
+ parts.append(f"\nConfidence: {confidence}")
187
+
188
+ return "\n".join(parts) if parts else "No results found."
189
+
190
+
191
+ @mcp.tool()
192
+ async def memory_forget(
193
+ reason: str,
194
+ query: str | None = None,
195
+ tenant_id: str | None = None,
196
+ ) -> str:
197
+ """Delete memories from CortexDB (supports GDPR erasure).
198
+
199
+ Parameters
200
+ ----------
201
+ reason:
202
+ Reason for the deletion (required for audit trail).
203
+ query:
204
+ Optional query to select which memories to forget.
205
+ tenant_id:
206
+ Tenant scope for multi-tenant deployments.
207
+ """
208
+ body: dict[str, Any] = {"reason": reason}
209
+ if query is not None:
210
+ body["query"] = query
211
+ if tenant_id is not None:
212
+ body["tenant_id"] = tenant_id
213
+
214
+ result = await _request("POST", "/v1/forget", json_body=body)
215
+
216
+ entities = result.get("entities_removed", result.get("forgotten_entities", 0))
217
+ edges = result.get("edges_removed", result.get("forgotten_edges", 0))
218
+ audit_id = result.get("audit_id", "")
219
+ msg = f"Forgotten. Entities removed: {entities}, edges removed: {edges}."
220
+ if audit_id:
221
+ msg += f" Audit ID: {audit_id}"
222
+ return msg
223
+
224
+
225
+ @mcp.tool()
226
+ async def get_context(
227
+ topic: str,
228
+ tenant_id: str | None = None,
229
+ include_graph: bool = False,
230
+ ) -> str:
231
+ """Retrieve rich contextual information about a topic from CortexDB.
232
+
233
+ Performs deep retrieval combining keyword search, vector similarity, and
234
+ knowledge graph traversal to build comprehensive context.
235
+
236
+ Parameters
237
+ ----------
238
+ topic:
239
+ The entity or topic to retrieve context for.
240
+ tenant_id:
241
+ Tenant scope for multi-tenant deployments.
242
+ include_graph:
243
+ Whether to include graph relationships in the response.
244
+ """
245
+ body: dict[str, Any] = {
246
+ "query": topic,
247
+ "max_tokens": 4096,
248
+ "max_results": 50,
249
+ }
250
+ if tenant_id is not None:
251
+ body["tenant_id"] = tenant_id
252
+ if include_graph:
253
+ body["include_graph"] = True
254
+
255
+ result = await _request("POST", "/v1/recall", json_body=body)
256
+
257
+ context = result.get("context", "")
258
+ graph = result.get("graph")
259
+
260
+ parts: list[str] = []
261
+ if context:
262
+ parts.append(context)
263
+ if graph:
264
+ parts.append("\n--- Graph Relationships ---")
265
+ parts.append(json.dumps(graph, indent=2))
266
+
267
+ return "\n".join(parts) if parts else f"No context found for '{topic}'."
268
+
269
+
270
+ @mcp.tool()
271
+ async def advanced_search(
272
+ query: str,
273
+ tenant_id: str | None = None,
274
+ source: str | None = None,
275
+ episode_type: str | None = None,
276
+ time_range_start: str | None = None,
277
+ time_range_end: str | None = None,
278
+ limit: int = 20,
279
+ offset: int = 0,
280
+ ) -> str:
281
+ """Search CortexDB with structured filters.
282
+
283
+ Parameters
284
+ ----------
285
+ query:
286
+ Natural-language search query.
287
+ tenant_id:
288
+ Tenant scope.
289
+ source:
290
+ Filter by source (e.g. "slack", "github").
291
+ episode_type:
292
+ Filter by type (e.g. "incident", "code_change", "deployment").
293
+ time_range_start:
294
+ ISO 8601 start time (e.g. "2026-03-01T00:00:00Z").
295
+ time_range_end:
296
+ ISO 8601 end time.
297
+ limit:
298
+ Max results (default 20).
299
+ offset:
300
+ Pagination offset.
301
+ """
302
+ body: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
303
+ filters: dict[str, Any] = {}
304
+ if source:
305
+ filters["source"] = source
306
+ if episode_type:
307
+ filters["episode_type"] = episode_type
308
+ if time_range_start or time_range_end:
309
+ time_range: dict[str, str] = {}
310
+ if time_range_start:
311
+ time_range["start"] = time_range_start
312
+ if time_range_end:
313
+ time_range["end"] = time_range_end
314
+ filters["time_range"] = time_range
315
+ if filters:
316
+ body["filters"] = filters
317
+ if tenant_id:
318
+ body["tenant_id"] = tenant_id
319
+
320
+ result = await _request("POST", "/v1/search", json_body=body)
321
+
322
+ context = result.get("context", "")
323
+ episodes = result.get("episodes", [])
324
+ total = result.get("total_episodes", len(episodes))
325
+ confidence = result.get("confidence")
326
+
327
+ parts: list[str] = []
328
+ if context:
329
+ parts.append(context)
330
+ if episodes:
331
+ parts.append(f"\n--- {len(episodes)} of {total} episode(s) ---")
332
+ for ep in episodes:
333
+ ep_id = ep.get("episode_id", ep.get("id", ""))
334
+ ep_type = ep.get("episode_type", "")
335
+ created = ep.get("created_at", "")
336
+ snippet = (ep.get("content", "")[:150] + "...") if ep.get("content") else ""
337
+ parts.append(f" [{ep_type}] {ep_id} {created}")
338
+ if snippet:
339
+ parts.append(f" {snippet}")
340
+ if confidence is not None:
341
+ parts.append(f"\nConfidence: {confidence}")
342
+
343
+ return "\n".join(parts) if parts else "No results found."
344
+
345
+
346
+ # ===========================================================================
347
+ # TOOLS — Episode CRUD
348
+ # ===========================================================================
349
+
350
+
351
+ @mcp.tool()
352
+ async def memory_list(
353
+ tenant_id: str | None = None,
354
+ limit: int = 20,
355
+ offset: int = 0,
356
+ episode_type: str | None = None,
357
+ ) -> str:
358
+ """List memories / episodes stored in CortexDB with pagination.
359
+
360
+ Parameters
361
+ ----------
362
+ tenant_id:
363
+ Tenant scope for multi-tenant deployments.
364
+ limit:
365
+ Maximum number of episodes to return (default 20).
366
+ offset:
367
+ Pagination offset (default 0).
368
+ episode_type:
369
+ Optional filter by episode type.
370
+ """
371
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
372
+ if episode_type is not None:
373
+ params["episode_type"] = episode_type
374
+
375
+ extra_headers: dict[str, str] = {}
376
+ if tenant_id is not None:
377
+ extra_headers["x-tenant-id"] = tenant_id
378
+
379
+ result = await _request(
380
+ "GET", "/v1/episodes", params=params, extra_headers=extra_headers or None
381
+ )
382
+
383
+ episodes = result.get("episodes", result.get("items", []))
384
+ total = result.get("total", len(episodes))
385
+
386
+ if not episodes:
387
+ return "No episodes found."
388
+
389
+ parts: list[str] = [f"Showing {len(episodes)} of {total} episode(s):\n"]
390
+ for ep in episodes:
391
+ ep_id = ep.get("episode_id", ep.get("id", "unknown"))
392
+ ep_type = ep.get("episode_type", "unknown")
393
+ created = ep.get("created_at", "")
394
+ snippet = (ep.get("content", "")[:120] + "...") if ep.get("content") else ""
395
+ parts.append(f" [{ep_type}] {ep_id} {created}")
396
+ if snippet:
397
+ parts.append(f" {snippet}")
398
+ return "\n".join(parts)
399
+
400
+
401
+ @mcp.tool()
402
+ async def memory_get(
403
+ episode_id: str,
404
+ tenant_id: str | None = None,
405
+ ) -> str:
406
+ """Get a specific memory / episode by its ID.
407
+
408
+ Parameters
409
+ ----------
410
+ episode_id:
411
+ The unique identifier of the episode to retrieve.
412
+ tenant_id:
413
+ Tenant scope for multi-tenant deployments.
414
+ """
415
+ extra_headers: dict[str, str] = {}
416
+ if tenant_id is not None:
417
+ extra_headers["x-tenant-id"] = tenant_id
418
+
419
+ result = await _request(
420
+ "GET", f"/v1/episodes/{episode_id}", extra_headers=extra_headers or None
421
+ )
422
+
423
+ parts: list[str] = [
424
+ f"Episode: {result.get('episode_id', result.get('id', episode_id))}",
425
+ f"Type: {result.get('episode_type', 'unknown')}",
426
+ ]
427
+ if result.get("created_at"):
428
+ parts.append(f"Created: {result['created_at']}")
429
+ if result.get("source"):
430
+ parts.append(f"Source: {result['source']}")
431
+ if result.get("tags"):
432
+ parts.append(f"Tags: {', '.join(result['tags'])}")
433
+ if result.get("content"):
434
+ parts.append(f"\nContent:\n{result['content']}")
435
+ if result.get("metadata"):
436
+ parts.append(f"\nMetadata:\n{json.dumps(result['metadata'], indent=2)}")
437
+ return "\n".join(parts)
438
+
439
+
440
+ @mcp.tool()
441
+ async def memory_update(
442
+ episode_id: str,
443
+ content: str,
444
+ tenant_id: str | None = None,
445
+ metadata: dict[str, Any] | None = None,
446
+ ) -> str:
447
+ """Update an existing memory's content or metadata.
448
+
449
+ Parameters
450
+ ----------
451
+ episode_id:
452
+ The unique identifier of the episode to update.
453
+ content:
454
+ New textual content for the episode.
455
+ tenant_id:
456
+ Tenant scope for multi-tenant deployments.
457
+ metadata:
458
+ Optional metadata dictionary to merge into the episode.
459
+ """
460
+ body: dict[str, Any] = {"content": content}
461
+ if metadata is not None:
462
+ body["metadata"] = metadata
463
+
464
+ extra_headers: dict[str, str] = {}
465
+ if tenant_id is not None:
466
+ extra_headers["x-tenant-id"] = tenant_id
467
+
468
+ result = await _request(
469
+ "PUT", f"/v1/episodes/{episode_id}",
470
+ json_body=body, extra_headers=extra_headers or None,
471
+ )
472
+
473
+ updated_id = result.get("episode_id", result.get("id", episode_id))
474
+ return f"Updated successfully. Episode ID: {updated_id}"
475
+
476
+
477
+ @mcp.tool()
478
+ async def memory_delete(
479
+ episode_id: str,
480
+ tenant_id: str | None = None,
481
+ ) -> str:
482
+ """Delete a specific episode by ID.
483
+
484
+ Parameters
485
+ ----------
486
+ episode_id:
487
+ The unique identifier of the episode to delete.
488
+ tenant_id:
489
+ Tenant scope for multi-tenant deployments.
490
+ """
491
+ extra_headers: dict[str, str] = {}
492
+ if tenant_id is not None:
493
+ extra_headers["x-tenant-id"] = tenant_id
494
+
495
+ await _request(
496
+ "DELETE", f"/v1/episodes/{episode_id}", extra_headers=extra_headers or None
497
+ )
498
+ return f"Deleted episode {episode_id}."
499
+
500
+
501
+ @mcp.tool()
502
+ async def memory_bulk_delete(
503
+ query: str,
504
+ reason: str,
505
+ tenant_id: str | None = None,
506
+ dry_run: bool = False,
507
+ ) -> str:
508
+ """Delete multiple memories matching a query pattern.
509
+
510
+ Parameters
511
+ ----------
512
+ query:
513
+ Search query to select memories for deletion.
514
+ reason:
515
+ Reason for the deletion (audit trail / GDPR).
516
+ tenant_id:
517
+ Tenant scope for multi-tenant deployments.
518
+ dry_run:
519
+ If True, return the count of matching episodes without deleting.
520
+ """
521
+ body: dict[str, Any] = {"query": query, "reason": reason, "dry_run": dry_run}
522
+ if tenant_id is not None:
523
+ body["tenant_id"] = tenant_id
524
+
525
+ result = await _request("POST", "/v1/forget", json_body=body)
526
+
527
+ deleted = result.get("deleted", result.get("entities_removed", 0))
528
+ if dry_run:
529
+ return f"Dry run: {deleted} episode(s) would be deleted."
530
+ return f"Bulk delete complete. {deleted} episode(s) removed."
531
+
532
+
533
+ # ===========================================================================
534
+ # TOOLS — Knowledge Graph (Entities & Links)
535
+ # ===========================================================================
536
+
537
+
538
+ @mcp.tool()
539
+ async def entity_list(
540
+ tenant_id: str | None = None,
541
+ limit: int = 50,
542
+ offset: int = 0,
543
+ ) -> str:
544
+ """List entities in the CortexDB knowledge graph.
545
+
546
+ Entities are automatically extracted from memories — people, services,
547
+ projects, concepts, and more.
548
+
549
+ Parameters
550
+ ----------
551
+ tenant_id:
552
+ Tenant scope for multi-tenant deployments.
553
+ limit:
554
+ Maximum number of entities to return (default 50).
555
+ offset:
556
+ Pagination offset.
557
+ """
558
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
559
+ if tenant_id:
560
+ params["tenant_id"] = tenant_id
561
+
562
+ result = await _request("GET", "/v1/entities", params=params)
563
+
564
+ entities = result.get("entities", result.get("items", []))
565
+ if not entities:
566
+ return "No entities found."
567
+
568
+ parts: list[str] = [f"Found {len(entities)} entity/entities:\n"]
569
+ for ent in entities:
570
+ eid = ent.get("id", "")
571
+ name = ent.get("name", "unnamed")
572
+ etype = ent.get("entity_type", "unknown")
573
+ ep_count = ent.get("episode_count", 0)
574
+ parts.append(f" [{etype}] {name} (id: {eid}, {ep_count} episodes)")
575
+ return "\n".join(parts)
576
+
577
+
578
+ @mcp.tool()
579
+ async def entity_get(
580
+ entity_id: str,
581
+ tenant_id: str | None = None,
582
+ ) -> str:
583
+ """Get detailed information about a specific entity including relationships.
584
+
585
+ Parameters
586
+ ----------
587
+ entity_id:
588
+ The unique identifier of the entity.
589
+ tenant_id:
590
+ Tenant scope for multi-tenant deployments.
591
+ """
592
+ params: dict[str, Any] = {}
593
+ if tenant_id:
594
+ params["tenant_id"] = tenant_id
595
+
596
+ result = await _request("GET", f"/v1/entities/{entity_id}", params=params)
597
+
598
+ parts: list[str] = [
599
+ f"Entity: {result.get('name', 'unnamed')}",
600
+ f"Type: {result.get('entity_type', 'unknown')}",
601
+ f"ID: {result.get('id', entity_id)}",
602
+ ]
603
+ if result.get("first_seen"):
604
+ parts.append(f"First seen: {result['first_seen']}")
605
+ if result.get("last_seen"):
606
+ parts.append(f"Last seen: {result['last_seen']}")
607
+ if result.get("episode_count"):
608
+ parts.append(f"Episodes: {result['episode_count']}")
609
+
610
+ rels = result.get("relationships", [])
611
+ if rels:
612
+ parts.append(f"\n--- {len(rels)} Relationship(s) ---")
613
+ for rel in rels:
614
+ direction = rel.get("direction", "")
615
+ rel_type = rel.get("relationship", rel.get("type", ""))
616
+ target = rel.get("target_name", rel.get("target_id", ""))
617
+ parts.append(f" {direction} {rel_type} -> {target}")
618
+
619
+ recent = result.get("recent_episodes", [])
620
+ if recent:
621
+ parts.append(f"\n--- Recent Episodes ({len(recent)}) ---")
622
+ for ep in recent:
623
+ ep_type = ep.get("episode_type", "")
624
+ created = ep.get("created_at", "")
625
+ snippet = (ep.get("content", "")[:100] + "...") if ep.get("content") else ""
626
+ parts.append(f" [{ep_type}] {created}")
627
+ if snippet:
628
+ parts.append(f" {snippet}")
629
+
630
+ return "\n".join(parts)
631
+
632
+
633
+ @mcp.tool()
634
+ async def entity_edges(
635
+ entity_id: str,
636
+ tenant_id: str | None = None,
637
+ ) -> str:
638
+ """Get all relationships (edges) for an entity in the knowledge graph.
639
+
640
+ Parameters
641
+ ----------
642
+ entity_id:
643
+ The unique identifier of the entity.
644
+ tenant_id:
645
+ Tenant scope for multi-tenant deployments.
646
+ """
647
+ params: dict[str, Any] = {}
648
+ if tenant_id:
649
+ params["tenant_id"] = tenant_id
650
+
651
+ result = await _request("GET", f"/v1/entities/{entity_id}/edges", params=params)
652
+
653
+ edges = result.get("edges", result.get("items", []))
654
+ if not edges:
655
+ return f"No relationships found for entity {entity_id}."
656
+
657
+ parts: list[str] = [f"Found {len(edges)} relationship(s):\n"]
658
+ for edge in edges:
659
+ rel = edge.get("relationship", edge.get("type", "unknown"))
660
+ source = edge.get("source_name", edge.get("source_id", ""))
661
+ target = edge.get("target_name", edge.get("target_id", ""))
662
+ confidence = edge.get("confidence", "")
663
+ fact = edge.get("fact", "")
664
+ line = f" {source} --[{rel}]--> {target}"
665
+ if confidence:
666
+ line += f" (confidence: {confidence})"
667
+ parts.append(line)
668
+ if fact:
669
+ parts.append(f" Fact: {fact}")
670
+ return "\n".join(parts)
671
+
672
+
673
+ @mcp.tool()
674
+ async def entity_link(
675
+ source_entity_id: str,
676
+ target_entity_id: str,
677
+ relationship: str,
678
+ fact: str = "",
679
+ confidence: float = 0.8,
680
+ tenant_id: str | None = None,
681
+ ) -> str:
682
+ """Create a relationship between two entities in the knowledge graph.
683
+
684
+ Parameters
685
+ ----------
686
+ source_entity_id:
687
+ ID of the source entity.
688
+ target_entity_id:
689
+ ID of the target entity.
690
+ relationship:
691
+ Relationship type (e.g. "DEPENDS_ON", "OWNS", "RELATED_TO").
692
+ fact:
693
+ Human-readable description of the relationship.
694
+ confidence:
695
+ Confidence score from 0.0 to 1.0 (default 0.8).
696
+ tenant_id:
697
+ Tenant scope for multi-tenant deployments.
698
+ """
699
+ body: dict[str, Any] = {
700
+ "source_entity_id": source_entity_id,
701
+ "target_entity_id": target_entity_id,
702
+ "relationship": relationship,
703
+ "fact": fact,
704
+ "confidence": confidence,
705
+ }
706
+ if tenant_id:
707
+ body["tenant_id"] = tenant_id
708
+
709
+ result = await _request("POST", "/v1/link", json_body=body)
710
+
711
+ link_id = result.get("id", "unknown")
712
+ return f"Link created. ID: {link_id}"
713
+
714
+
715
+ # ===========================================================================
716
+ # TOOLS — Admin & Observability
717
+ # ===========================================================================
718
+
719
+
720
+ @mcp.tool()
721
+ async def health_check() -> str:
722
+ """Check CortexDB server health status."""
723
+ result = await _request("GET", "/v1/admin/health")
724
+ return json.dumps(result, indent=2)
725
+
726
+
727
+ @mcp.tool()
728
+ async def get_usage(
729
+ tenant_id: str | None = None,
730
+ ) -> str:
731
+ """Get current usage statistics and tier limits.
732
+
733
+ Shows memory count, recall calls, and how close you are to tier limits.
734
+
735
+ Parameters
736
+ ----------
737
+ tenant_id:
738
+ Tenant scope (uses API key's default tenant if omitted).
739
+ """
740
+ result = await _request("GET", "/v1/admin/usage")
741
+
742
+ tier = result.get("tier", "unknown")
743
+ usage = result.get("usage", {})
744
+ limits = result.get("limits", {})
745
+
746
+ mem = usage.get("memories", {})
747
+ recalls = usage.get("recalls", {})
748
+
749
+ parts: list[str] = [
750
+ f"Tier: {tier}",
751
+ "",
752
+ "Memories:",
753
+ f" Total: {mem.get('total', 0):,}",
754
+ f" Limit: {limits.get('max_memories', 'unknown')}",
755
+ f" Utilization: {mem.get('utilization_pct', 0):.1f}%",
756
+ "",
757
+ "Recall Calls (this month):",
758
+ f" Used: {recalls.get('this_month', 0):,}",
759
+ f" Limit: {limits.get('max_recalls_per_month', 'unknown')}",
760
+ f" Utilization: {recalls.get('utilization_pct', 0):.1f}%",
761
+ "",
762
+ f"Entities: {usage.get('entities_count', 0):,}",
763
+ f"Lifetime recalls: {recalls.get('lifetime', 0):,}",
764
+ ]
765
+ return "\n".join(parts)
766
+
767
+
768
+ @mcp.tool()
769
+ async def get_insights(
770
+ tenant_id: str | None = None,
771
+ ) -> str:
772
+ """Generate proactive insights from CortexDB episodes.
773
+
774
+ Analyzes stored episodes to surface actionable intelligence such as
775
+ incident spikes, new dependencies, knowledge gaps, deployment risks,
776
+ stale documentation, and recurring issues.
777
+
778
+ Parameters
779
+ ----------
780
+ tenant_id:
781
+ Tenant scope for multi-tenant deployments.
782
+ """
783
+ engine = InsightsEngine(
784
+ cortex_url=_config.url,
785
+ api_key=_config.api_key,
786
+ tenant_id=tenant_id,
787
+ )
788
+ insights = await engine.generate_all()
789
+
790
+ if not insights:
791
+ return "No insights generated. Everything looks stable."
792
+
793
+ parts: list[str] = [f"Generated {len(insights)} insight(s):\n"]
794
+ for insight in insights:
795
+ severity_tag = f"[{insight.severity.value.upper()}]"
796
+ parts.append(f"{severity_tag} {insight.title}")
797
+ parts.append(f" {insight.description}")
798
+ if insight.entities:
799
+ parts.append(f" Entities: {', '.join(insight.entities)}")
800
+ parts.append("")
801
+
802
+ return "\n".join(parts)
803
+
804
+
805
+ @mcp.tool()
806
+ async def get_ontology() -> str:
807
+ """Get the entity types and relationship types known to CortexDB.
808
+
809
+ Useful for understanding what kinds of entities and relationships
810
+ exist in the knowledge graph.
811
+ """
812
+ types_result = await _request("GET", "/v1/admin/ontology/types")
813
+ rels_result = await _request("GET", "/v1/admin/ontology/relationships")
814
+
815
+ entity_types = types_result.get("entity_types", [])
816
+ rel_types = rels_result.get("relationship_types", [])
817
+
818
+ parts: list[str] = [f"Entity Types ({len(entity_types)}):\n"]
819
+ for et in entity_types:
820
+ name = et.get("name", "")
821
+ desc = et.get("description", "")
822
+ parts.append(f" {name}: {desc}" if desc else f" {name}")
823
+
824
+ parts.append(f"\nRelationship Types ({len(rel_types)}):\n")
825
+ for rt in rel_types:
826
+ name = rt.get("name", "")
827
+ desc = rt.get("description", "")
828
+ parts.append(f" {name}: {desc}" if desc else f" {name}")
829
+
830
+ return "\n".join(parts)
831
+
832
+
833
+ @mcp.tool()
834
+ async def export_data(
835
+ tenant_id: str | None = None,
836
+ format: str = "json",
837
+ ) -> str:
838
+ """Export memories from CortexDB.
839
+
840
+ Parameters
841
+ ----------
842
+ tenant_id:
843
+ Tenant scope (exports all data for this tenant).
844
+ format:
845
+ Export format: "json" (default).
846
+ """
847
+ body: dict[str, Any] = {"format": format}
848
+ if tenant_id:
849
+ body["tenant_id"] = tenant_id
850
+
851
+ result = await _request("POST", "/v1/export", json_body=body)
852
+
853
+ count = result.get("episode_count", result.get("count", 0))
854
+ export_id = result.get("export_id", "")
855
+ return f"Export complete. {count} episodes exported. Export ID: {export_id}"
856
+
857
+
858
+ @mcp.tool()
859
+ async def import_data(
860
+ data: str,
861
+ tenant_id: str | None = None,
862
+ format: str = "json",
863
+ ) -> str:
864
+ """Import memories into CortexDB.
865
+
866
+ Parameters
867
+ ----------
868
+ data:
869
+ JSON string of episodes to import.
870
+ tenant_id:
871
+ Tenant scope for the imported data.
872
+ format:
873
+ Import format: "json" (default).
874
+ """
875
+ body: dict[str, Any] = {"data": data, "format": format}
876
+ if tenant_id:
877
+ body["tenant_id"] = tenant_id
878
+
879
+ result = await _request("POST", "/v1/import", json_body=body)
880
+
881
+ imported = result.get("imported", result.get("count", 0))
882
+ return f"Import complete. {imported} episodes imported."
883
+
884
+
885
+ # ===========================================================================
886
+ # RESOURCES
887
+ # ===========================================================================
888
+
889
+
890
+ @mcp.resource("cortexdb://health")
891
+ async def health_resource() -> str:
892
+ """CortexDB server health status."""
893
+ result = await _request("GET", "/v1/admin/health")
894
+ return json.dumps(result, indent=2)
895
+
896
+
897
+ @mcp.resource("cortexdb://metrics")
898
+ async def metrics_resource() -> str:
899
+ """CortexDB server metrics (requests, errors, rate limits)."""
900
+ try:
901
+ result = await _request("GET", "/v1/admin/metrics")
902
+ return json.dumps(result, indent=2)
903
+ except RuntimeError:
904
+ return json.dumps({"error": "Metrics endpoint unavailable."})
905
+
906
+
907
+ @mcp.resource("cortexdb://usage")
908
+ async def usage_resource() -> str:
909
+ """Current usage statistics and tier limits."""
910
+ try:
911
+ result = await _request("GET", "/v1/admin/usage")
912
+ return json.dumps(result, indent=2)
913
+ except RuntimeError:
914
+ return json.dumps({"error": "Usage endpoint unavailable."})
915
+
916
+
917
+ @mcp.resource("cortexdb://episodes")
918
+ async def episodes_resource() -> str:
919
+ """Recent episodes stored in CortexDB (last 50)."""
920
+ try:
921
+ result = await _request(
922
+ "GET", "/v1/episodes", params={"limit": 50, "offset": 0}
923
+ )
924
+ episodes = result.get("episodes", result.get("items", []))
925
+ return json.dumps(episodes, indent=2)
926
+ except RuntimeError:
927
+ return json.dumps({"error": "Episodes endpoint unavailable."})
928
+
929
+
930
+ @mcp.resource("cortexdb://entities")
931
+ async def entities_resource() -> str:
932
+ """Entities in the CortexDB knowledge graph (top 100)."""
933
+ try:
934
+ result = await _request("GET", "/v1/entities", params={"limit": 100})
935
+ entities = result.get("entities", result.get("items", []))
936
+ return json.dumps(entities, indent=2)
937
+ except RuntimeError:
938
+ return json.dumps({"error": "Entities endpoint unavailable."})
939
+
940
+
941
+ @mcp.resource("cortexdb://insights")
942
+ async def insights_resource() -> str:
943
+ """Proactive insights generated from stored episodes."""
944
+ engine = InsightsEngine(
945
+ cortex_url=_config.url,
946
+ api_key=_config.api_key,
947
+ )
948
+ try:
949
+ insights = await engine.generate_all()
950
+ return json.dumps([i.to_dict() for i in insights], indent=2)
951
+ except Exception as exc:
952
+ return json.dumps({"error": str(exc)})
953
+
954
+
955
+ @mcp.resource("cortexdb://ontology")
956
+ async def ontology_resource() -> str:
957
+ """Entity types and relationship types in the knowledge graph schema."""
958
+ try:
959
+ types_result = await _request("GET", "/v1/admin/ontology/types")
960
+ rels_result = await _request("GET", "/v1/admin/ontology/relationships")
961
+ return json.dumps({
962
+ "entity_types": types_result.get("entity_types", []),
963
+ "relationship_types": rels_result.get("relationship_types", []),
964
+ }, indent=2)
965
+ except RuntimeError:
966
+ return json.dumps({"error": "Ontology endpoints unavailable."})
967
+
968
+
969
+ # ===========================================================================
970
+ # PROMPTS
971
+ # ===========================================================================
972
+
973
+
974
+ @mcp.prompt()
975
+ def investigate_incident(topic: str) -> str:
976
+ """Investigate an incident using CortexDB memory.
977
+
978
+ Parameters
979
+ ----------
980
+ topic:
981
+ The incident or topic to investigate.
982
+ """
983
+ return (
984
+ f"Given the context from CortexDB, investigate this incident: {topic}\n\n"
985
+ "Steps:\n"
986
+ "1. Search CortexDB for all memories related to the incident.\n"
987
+ "2. Identify the timeline of events.\n"
988
+ "3. Determine root cause based on available evidence.\n"
989
+ "4. Suggest remediation steps.\n"
990
+ )
991
+
992
+
993
+ @mcp.prompt()
994
+ def summarize_knowledge(topic: str) -> str:
995
+ """Summarize everything CortexDB knows about a topic.
996
+
997
+ Parameters
998
+ ----------
999
+ topic:
1000
+ The topic to summarize knowledge for.
1001
+ """
1002
+ return (
1003
+ f"Summarize everything CortexDB knows about: {topic}\n\n"
1004
+ "Include:\n"
1005
+ "- Key facts and relationships\n"
1006
+ "- Timeline of relevant events\n"
1007
+ "- Confidence levels for each piece of information\n"
1008
+ "- Any gaps in knowledge\n"
1009
+ )
1010
+
1011
+
1012
+ @mcp.prompt()
1013
+ def deployment_review(service: str) -> str:
1014
+ """Pre-deployment review using CortexDB memory.
1015
+
1016
+ Parameters
1017
+ ----------
1018
+ service:
1019
+ The name of the service being deployed.
1020
+ """
1021
+ return (
1022
+ f"Perform a deployment review for service: {service}\n\n"
1023
+ "Using CortexDB memory, evaluate:\n"
1024
+ "1. Recent incidents or outages related to this service.\n"
1025
+ "2. Dependencies and downstream consumers that may be affected.\n"
1026
+ "3. Configuration changes in the last 7 days.\n"
1027
+ "4. Open issues or known risks flagged in previous deployments.\n"
1028
+ "5. Rollback plan — is there a safe previous version?\n\n"
1029
+ "Provide a go/no-go recommendation with supporting evidence.\n"
1030
+ )
1031
+
1032
+
1033
+ @mcp.prompt()
1034
+ def onboard_to_codebase(repo: str) -> str:
1035
+ """Onboard to a codebase using CortexDB's stored knowledge.
1036
+
1037
+ Parameters
1038
+ ----------
1039
+ repo:
1040
+ The repository or project name.
1041
+ """
1042
+ return (
1043
+ f"Help me understand the {repo} codebase using CortexDB.\n\n"
1044
+ "Please:\n"
1045
+ "1. Search for architecture decisions and design documents.\n"
1046
+ "2. List the key entities (services, modules, teams).\n"
1047
+ "3. Show dependency relationships from the knowledge graph.\n"
1048
+ "4. Summarize recent changes and ongoing work.\n"
1049
+ "5. Highlight any known issues or technical debt.\n"
1050
+ )
1051
+
1052
+
1053
+ @mcp.prompt()
1054
+ def weekly_digest(tenant_id: str | None = None) -> str:
1055
+ """Generate a weekly activity digest from CortexDB.
1056
+
1057
+ Parameters
1058
+ ----------
1059
+ tenant_id:
1060
+ Optional tenant scope.
1061
+ """
1062
+ scope = f" for tenant {tenant_id}" if tenant_id else ""
1063
+ return (
1064
+ f"Generate a weekly digest{scope} from CortexDB.\n\n"
1065
+ "Include:\n"
1066
+ "1. Summary of all episodes ingested this week (by type and source).\n"
1067
+ "2. New entities discovered.\n"
1068
+ "3. Key decisions and their context.\n"
1069
+ "4. Incidents and their current status.\n"
1070
+ "5. Proactive insights and recommendations.\n"
1071
+ )
1072
+
1073
+
1074
+ # ---------------------------------------------------------------------------
1075
+ # Entry point
1076
+ # ---------------------------------------------------------------------------
1077
+
1078
+
1079
+ def main() -> None:
1080
+ """Run the CortexDB MCP server over stdio transport."""
1081
+ mcp.run(transport="stdio")
1082
+
1083
+
1084
+ if __name__ == "__main__":
1085
+ main()