cortexdb-mcp 0.3.3__tar.gz → 0.5.0__tar.gz

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.
@@ -77,3 +77,6 @@ blog/
77
77
  sales/
78
78
  videos/
79
79
  local-instance/
80
+
81
+ # doc-claims verifier scratch output
82
+ tools/verifier_out*/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-mcp
3
- Version: 0.3.3
3
+ Version: 0.5.0
4
4
  Summary: MCP Server for CortexDB — expose memory operations to AI agents
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -157,6 +157,7 @@ On Windows, MCP clients sometimes need the absolute path:
157
157
  | Tool | Maps to | Description |
158
158
  |------|---------|-------------|
159
159
  | `memory_store` | `POST /v1/experience` | Store a new memory. Source/tags/type become labels. |
160
+ | `memory_status` | `GET /v1/experience/status` | Did a store land? Check by event ID or idempotency key after a timeout — "not found"/"failed" both mean re-storing is safe. |
160
161
  | `memory_search` | `POST /v1/recall` | Search memories using natural language. |
161
162
  | `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. |
162
163
  | `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
@@ -180,6 +181,16 @@ On Windows, MCP clients sometimes need the absolute path:
180
181
  | `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
181
182
  | `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
182
183
 
184
+ ### Bi-temporal Conflicts & Claim History
185
+
186
+ | Tool | Maps to | Description |
187
+ |------|---------|-------------|
188
+ | `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
189
+ | `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
190
+ | `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
191
+
192
+ Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
193
+
183
194
  ### Admin & Observability
184
195
 
185
196
  | Tool | Maps to | Description |
@@ -144,6 +144,7 @@ On Windows, MCP clients sometimes need the absolute path:
144
144
  | Tool | Maps to | Description |
145
145
  |------|---------|-------------|
146
146
  | `memory_store` | `POST /v1/experience` | Store a new memory. Source/tags/type become labels. |
147
+ | `memory_status` | `GET /v1/experience/status` | Did a store land? Check by event ID or idempotency key after a timeout — "not found"/"failed" both mean re-storing is safe. |
147
148
  | `memory_search` | `POST /v1/recall` | Search memories using natural language. |
148
149
  | `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. |
149
150
  | `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
@@ -167,6 +168,16 @@ On Windows, MCP clients sometimes need the absolute path:
167
168
  | `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
168
169
  | `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
169
170
 
171
+ ### Bi-temporal Conflicts & Claim History
172
+
173
+ | Tool | Maps to | Description |
174
+ |------|---------|-------------|
175
+ | `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
176
+ | `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
177
+ | `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
178
+
179
+ Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
180
+
170
181
  ### Admin & Observability
171
182
 
172
183
  | Tool | Maps to | Description |
@@ -1,3 +1,3 @@
1
1
  """CortexDB MCP Server -- expose CortexDB memory operations to AI agents via MCP."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.5.0"
@@ -87,6 +87,68 @@ def fact_text(f: dict[str, Any]) -> str:
87
87
  return " ".join(p for p in (subj_text, predicate, obj_text) if p)
88
88
 
89
89
 
90
+ def _boundary_date(iso: str) -> str:
91
+ """Trim an RFC3339 boundary to its date part — validity boundaries are
92
+ day-grained after date grounding; the clock adds noise, not signal."""
93
+ return (iso or "")[:10]
94
+
95
+
96
+ def record_validity_text(r: dict[str, Any]) -> str:
97
+ """Render a RecordView's valid interval honoring ``BoundaryBasis``.
98
+
99
+ THE rendering rule (BITEMPORAL_CONFLICT_DESIGN §2): a boundary whose
100
+ basis is ``"observed"`` is only a knowledge bound — it must read as
101
+ "by <date>" (we knew it held/ended by then), NEVER "on/since <date>".
102
+ Stated boundaries render as "since <date>" / "until <date>".
103
+ """
104
+ parts: list[str] = []
105
+ vf, vt = r.get("valid_from"), r.get("valid_to")
106
+ if vf:
107
+ d = _boundary_date(str(vf))
108
+ parts.append(
109
+ f"held by {d}" if r.get("valid_from_basis") == "observed" else f"since {d}"
110
+ )
111
+ if vt:
112
+ d = _boundary_date(str(vt))
113
+ parts.append(
114
+ f"ended by {d}" if r.get("valid_to_basis") == "observed" else f"until {d}"
115
+ )
116
+ return ", ".join(parts) if parts else "undated"
117
+
118
+
119
+ def record_text(r: dict[str, Any]) -> str:
120
+ """Render one bi-temporal RecordView to a readable line:
121
+ ``<value> (<validity>; recorded <date>[; <status>][; conf NN%])``."""
122
+ value = "[redacted]" if r.get("redacted") else str(r.get("object") or "?")
123
+ bits: list[str] = [record_validity_text(r)]
124
+ recorded = _boundary_date(str(r.get("recorded_from") or ""))
125
+ if recorded:
126
+ bits.append(f"recorded {recorded}")
127
+ status = r.get("status")
128
+ if status and status != "active":
129
+ bits.append(str(status))
130
+ conf = r.get("confidence")
131
+ if isinstance(conf, (int, float)):
132
+ bits.append(f"conf {conf:.0%}")
133
+ return f"{value} ({'; '.join(bits)})"
134
+
135
+
136
+ def conflict_lines(c: dict[str, Any]) -> list[str]:
137
+ """Render a ConflictView as a header line plus one indented line per
138
+ member record (each honoring the observed→"by" boundary rule)."""
139
+ header = (
140
+ f"{c.get('id', '?')} [{c.get('kind', '?')}] "
141
+ f"{c.get('subject', '?')} {c.get('predicate', '?')} — {c.get('status', '?')}"
142
+ )
143
+ lines = [header]
144
+ for r in c.get("records", []) or []:
145
+ lines.append(f" - {record_text(r)}")
146
+ resolved_by = c.get("resolved_by")
147
+ if resolved_by:
148
+ lines.append(f" resolved by {resolved_by} at {c.get('resolved_at', '?')}")
149
+ return lines
150
+
151
+
90
152
  def belief_statement(b: dict[str, Any]) -> str:
91
153
  """The human-readable statement a belief asserts (claim.object.value). The
92
154
  claim subject is an internal topic key and is deliberately not used."""
@@ -33,9 +33,11 @@ from cortexdb_mcp.config import CortexMCPConfig
33
33
  from cortexdb_mcp.insights import InsightsEngine
34
34
  from cortexdb_mcp.render import (
35
35
  belief_text as _belief_text,
36
+ conflict_lines as _conflict_lines,
36
37
  fact_text as _fact_text,
37
38
  is_synthetic_subject as _is_synthetic_subject,
38
39
  memories_from_context as _memories_from_context,
40
+ record_text as _record_text,
39
41
  typed_value_text as _typed_value_text,
40
42
  )
41
43
 
@@ -251,6 +253,57 @@ async def memory_store(
251
253
  return f"Stored successfully. Event ID: {event_id}{warning_suffix}"
252
254
 
253
255
 
256
+ @mcp.tool()
257
+ async def memory_status(
258
+ event_id: str | None = None,
259
+ idempotency_key: str | None = None,
260
+ ) -> str:
261
+ """Check whether a stored memory landed and how far it has progressed,
262
+ via GET /v1/experience/status. Use this after a memory_store call timed
263
+ out or errored to decide whether to retry: "not found" or "failed" both
264
+ mean re-storing the same content is safe (it re-processes, never
265
+ duplicates).
266
+
267
+ Parameters
268
+ ----------
269
+ event_id:
270
+ The ``Event ID`` returned by ``memory_store``.
271
+ idempotency_key:
272
+ Alternatively, the idempotency key of the original write
273
+ (keys are kept for 24 hours).
274
+ """
275
+ if not event_id and not idempotency_key:
276
+ return "Pass event_id (from memory_store) or idempotency_key."
277
+ params: dict[str, Any] = {}
278
+ if event_id:
279
+ params["event_id"] = event_id
280
+ if idempotency_key:
281
+ params["idempotency_key"] = idempotency_key
282
+ result = await _request("GET", "/v1/experience/status", params=params)
283
+ if not result.get("found"):
284
+ return (
285
+ "Not found: never stored, expired (>24h), or failed and evicted. "
286
+ "Re-storing the same content is safe."
287
+ )
288
+ status = result.get("status", "unknown")
289
+ stages = ", ".join(result.get("stages_completed", [])) or "none"
290
+ line = (
291
+ f"Status: {status}. Event ID: {result.get('event_id', 'unknown')}. "
292
+ f"Stages completed: {stages}."
293
+ )
294
+ if status == "failed":
295
+ reason = result.get("failure_reason", "unknown")
296
+ line += (
297
+ f" Indexing failed ({reason}) — the memory is NOT searchable; "
298
+ "re-store the same content to re-process it."
299
+ )
300
+ elif status == "indexed":
301
+ line += " The memory is stored and searchable."
302
+ else:
303
+ line += " Accepted and durable; indexing still in progress."
304
+ return line
305
+
306
+
254
307
  @mcp.tool()
255
308
  async def memory_search(
256
309
  query: str,
@@ -722,6 +775,186 @@ async def entity_link(
722
775
  )
723
776
 
724
777
 
778
+ # ===========================================================================
779
+ # TOOLS — Bi-temporal conflicts & claim history
780
+ # ===========================================================================
781
+ #
782
+ # Rendering honors BoundaryBasis (BITEMPORAL_CONFLICT_DESIGN §2): boundaries
783
+ # whose basis is "observed" render as "by <date>" (via render.record_text),
784
+ # never "on/since <date>".
785
+
786
+ # Per-action argument contract for POST /v1/conflicts/{id}/resolve.
787
+ _RESOLVE_ACTIONS: dict[str, dict[str, tuple[str, ...]]] = {
788
+ "pick": {"required": ("winner",), "optional": ("reason",)},
789
+ "split": {"required": ("boundary", "earlier", "later"), "optional": ()},
790
+ "new_information": {"required": ("record",), "optional": ()},
791
+ "dismiss": {"required": (), "optional": ("reason",)},
792
+ }
793
+
794
+
795
+ def _resolve_body(action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
796
+ """Validate + build the resolve request body; raises ValueError with a
797
+ message the agent can act on (which field is missing/extraneous)."""
798
+ spec = _RESOLVE_ACTIONS.get(action)
799
+ if spec is None:
800
+ raise ValueError(
801
+ f"unknown action {action!r}; expected one of {sorted(_RESOLVE_ACTIONS)}"
802
+ )
803
+ allowed = set(spec["required"]) | set(spec["optional"])
804
+ provided = {k for k, v in kwargs.items() if v is not None}
805
+ missing = [k for k in spec["required"] if k not in provided]
806
+ if missing:
807
+ raise ValueError(f"action {action!r} requires {missing}")
808
+ extraneous = sorted(provided - allowed)
809
+ if extraneous:
810
+ raise ValueError(
811
+ f"action {action!r} does not accept {extraneous} (allowed: {sorted(allowed)})"
812
+ )
813
+ body: dict[str, Any] = {"action": action}
814
+ for k in allowed:
815
+ if kwargs.get(k) is not None:
816
+ body[k] = kwargs[k]
817
+ if action in ("pick", "dismiss"):
818
+ body.setdefault("reason", "")
819
+ return body
820
+
821
+
822
+ @mcp.tool()
823
+ async def list_conflicts(
824
+ tenant_id: str | None = None,
825
+ status: str = "open",
826
+ limit: int = 20,
827
+ ) -> str:
828
+ """List detected memory conflicts (contradicting values for the same
829
+ claim). Maps to GET /v1/conflicts.
830
+
831
+ Parameters
832
+ ----------
833
+ tenant_id:
834
+ Scope override (defaults to the configured scope).
835
+ status:
836
+ "open" (default) for unresolved conflicts only, or "all".
837
+ limit:
838
+ Max conflicts to return.
839
+ """
840
+ scope = _scope_for(tenant_id)
841
+ result = await _request(
842
+ "GET",
843
+ "/v1/conflicts",
844
+ params={"scope": scope, "status": status, "limit": str(limit)},
845
+ )
846
+ conflicts = result.get("conflicts", [])
847
+ total = result.get("total", len(conflicts))
848
+ if not conflicts:
849
+ qualifier = "open " if status != "all" else ""
850
+ return f"No {qualifier}conflicts in this scope."
851
+ parts: list[str] = [f"Showing {len(conflicts)} of {total} conflict(s):\n"]
852
+ for c in conflicts:
853
+ parts.extend(_conflict_lines(c))
854
+ parts.append("")
855
+ next_cursor = result.get("next_cursor")
856
+ if next_cursor is not None:
857
+ parts.append(f"More available (cursor={next_cursor}).")
858
+ return "\n".join(parts).rstrip()
859
+
860
+
861
+ @mcp.tool()
862
+ async def resolve_conflict(
863
+ conflict_id: str,
864
+ action: str,
865
+ winner: str | None = None,
866
+ reason: str | None = None,
867
+ boundary: str | None = None,
868
+ earlier: str | None = None,
869
+ later: str | None = None,
870
+ record: str | None = None,
871
+ ) -> str:
872
+ """Resolve a memory conflict. Maps to POST /v1/conflicts/{id}/resolve.
873
+
874
+ Parameters
875
+ ----------
876
+ conflict_id:
877
+ The conflict UUID (from list_conflicts).
878
+ action:
879
+ One of:
880
+ - "pick": one record wins outright — requires `winner` (record
881
+ UUID); `reason` optional.
882
+ - "split": both values were true at different times — requires
883
+ `boundary` (absolute date, e.g. "2024-03"), `earlier` and
884
+ `later` (record UUIDs).
885
+ - "new_information": the record supersedes the older value —
886
+ requires `record` (record UUID).
887
+ - "dismiss": not a real conflict — `reason` optional.
888
+ """
889
+ try:
890
+ body = _resolve_body(
891
+ action,
892
+ {
893
+ "winner": winner,
894
+ "reason": reason,
895
+ "boundary": boundary,
896
+ "earlier": earlier,
897
+ "later": later,
898
+ "record": record,
899
+ },
900
+ )
901
+ except ValueError as exc:
902
+ return f"Invalid resolution: {exc}"
903
+ result = await _request(
904
+ "POST", f"/v1/conflicts/{conflict_id}/resolve", json_body=body
905
+ )
906
+ if not result.get("id"):
907
+ return f"Conflict {conflict_id} resolved ({action})."
908
+ parts = [f"Conflict resolved ({action}):", ""]
909
+ parts.extend(_conflict_lines(result))
910
+ return "\n".join(parts)
911
+
912
+
913
+ @mcp.tool()
914
+ async def claim_history(
915
+ subject: str,
916
+ predicate: str,
917
+ tenant_id: str | None = None,
918
+ ) -> str:
919
+ """Show the full bi-temporal change-log of one claim — every valid
920
+ interval and every record-time correction, plus any conflicts involving
921
+ it. Maps to GET /v1/claims/history.
922
+
923
+ Parameters
924
+ ----------
925
+ subject:
926
+ The claim's subject entity (e.g. "Alice").
927
+ predicate:
928
+ The claim's predicate (e.g. "works_at").
929
+ tenant_id:
930
+ Scope override.
931
+ """
932
+ scope = _scope_for(tenant_id)
933
+ result = await _request(
934
+ "GET",
935
+ "/v1/claims/history",
936
+ params={"scope": scope, "subject": subject, "predicate": predicate},
937
+ )
938
+ claim = result.get("claim", {}) or {}
939
+ history = result.get("history", []) or []
940
+ conflicts = result.get("conflicts", []) or []
941
+ if not history:
942
+ return f"No recorded history for claim '{subject} {predicate}'."
943
+ parts: list[str] = [
944
+ f"History of {claim.get('subject', subject)} "
945
+ f"{claim.get('predicate', predicate)} "
946
+ f"({len(history)} record(s)):\n"
947
+ ]
948
+ for r in history:
949
+ parts.append(f" {_record_text(r)}")
950
+ if conflicts:
951
+ parts.append(f"\n{len(conflicts)} conflict(s) on this claim:")
952
+ for c in conflicts:
953
+ for line in _conflict_lines(c):
954
+ parts.append(f" {line}")
955
+ return "\n".join(parts)
956
+
957
+
725
958
  # ===========================================================================
726
959
  # TOOLS — Admin & Observability
727
960
  # ===========================================================================
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cortexdb-mcp"
7
- version = "0.3.3"
7
+ version = "0.5.0"
8
8
  description = "MCP Server for CortexDB — expose memory operations to AI agents"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -1,67 +1,67 @@
1
- """Live-instance smoke test for the v1 MCP server.
2
-
3
- Hits a real CortexDB v1 deployment. Skipped by default — set
4
- ``CORTEXDB_LIVE=1`` to run.
5
-
6
- Defaults to ``https://api-v1.cortexdb.ai`` and anonymous signup so the
7
- test needs no pre-existing API key. Override with the usual env vars
8
- (``CORTEXDB_URL``, ``CORTEXDB_API_KEY``, ``CORTEXDB_ACTOR``,
9
- ``CORTEXDB_SCOPE``) when targeting a private deployment.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import os
15
- import time
16
-
17
- import pytest
18
-
19
- # Hard gate — never runs in normal CI.
20
- if not os.environ.get("CORTEXDB_LIVE"):
21
- pytest.skip("set CORTEXDB_LIVE=1 to run", allow_module_level=True)
22
-
23
- # Stash a real API URL into env BEFORE importing the server module, since
24
- # `_config` is built at import time.
25
- os.environ.setdefault("CORTEXDB_URL", "https://api-v1.cortexdb.ai")
26
-
27
- from cortexdb_mcp.server import ( # noqa: E402
28
- health_check,
29
- memory_list,
30
- memory_search,
31
- memory_store,
32
- )
33
-
34
-
35
- @pytest.mark.asyncio
36
- async def test_health_check_returns_caller():
37
- """Health check proves the bearer works AND the deployment is reachable."""
38
- out = await health_check()
39
- assert "caller" in out
40
- assert "deployment_preset" in out
41
-
42
-
43
- @pytest.mark.asyncio
44
- async def test_store_then_search_roundtrip():
45
- """Full happy path: store a memory, wait for indexing, search it back."""
46
- marker = f"mcp-live-{int(time.time())}"
47
- store_out = await memory_store(
48
- content=f"MCP integration test event {marker}",
49
- source="mcp-integration-test",
50
- )
51
- assert "Stored successfully" in store_out
52
-
53
- # Give the v1 pipeline a beat to index. Async enrichment finishes
54
- # in ~4-10s; 15s margin keeps the test stable.
55
- time.sleep(15)
56
-
57
- search_out = await memory_search(query=marker, max_results=5)
58
- # The marker text should round-trip through recall.
59
- assert marker in search_out or "No results" in search_out
60
-
61
-
62
- @pytest.mark.asyncio
63
- async def test_memory_list_returns_recent():
64
- """List endpoint surfaces events the caller can see."""
65
- out = await memory_list(limit=3)
66
- # Either some events or the empty-list message — both are healthy.
67
- assert isinstance(out, str)
1
+ """Live-instance smoke test for the v1 MCP server.
2
+
3
+ Hits a real CortexDB v1 deployment. Skipped by default — set
4
+ ``CORTEXDB_LIVE=1`` to run.
5
+
6
+ Defaults to ``https://api-v1.cortexdb.ai`` and anonymous signup so the
7
+ test needs no pre-existing API key. Override with the usual env vars
8
+ (``CORTEXDB_URL``, ``CORTEXDB_API_KEY``, ``CORTEXDB_ACTOR``,
9
+ ``CORTEXDB_SCOPE``) when targeting a private deployment.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import time
16
+
17
+ import pytest
18
+
19
+ # Hard gate — never runs in normal CI.
20
+ if not os.environ.get("CORTEXDB_LIVE"):
21
+ pytest.skip("set CORTEXDB_LIVE=1 to run", allow_module_level=True)
22
+
23
+ # Stash a real API URL into env BEFORE importing the server module, since
24
+ # `_config` is built at import time.
25
+ os.environ.setdefault("CORTEXDB_URL", "https://api-v1.cortexdb.ai")
26
+
27
+ from cortexdb_mcp.server import ( # noqa: E402
28
+ health_check,
29
+ memory_list,
30
+ memory_search,
31
+ memory_store,
32
+ )
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_health_check_returns_caller():
37
+ """Health check proves the bearer works AND the deployment is reachable."""
38
+ out = await health_check()
39
+ assert "caller" in out
40
+ assert "deployment_preset" in out
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_store_then_search_roundtrip():
45
+ """Full happy path: store a memory, wait for indexing, search it back."""
46
+ marker = f"mcp-live-{int(time.time())}"
47
+ store_out = await memory_store(
48
+ content=f"MCP integration test event {marker}",
49
+ source="mcp-integration-test",
50
+ )
51
+ assert "Stored successfully" in store_out
52
+
53
+ # Give the v1 pipeline a beat to index. Async enrichment finishes
54
+ # in ~4-10s; 15s margin keeps the test stable.
55
+ time.sleep(15)
56
+
57
+ search_out = await memory_search(query=marker, max_results=5)
58
+ # The marker text should round-trip through recall.
59
+ assert marker in search_out or "No results" in search_out
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_memory_list_returns_recent():
64
+ """List endpoint surfaces events the caller can see."""
65
+ out = await memory_list(limit=3)
66
+ # Either some events or the empty-list message — both are healthy.
67
+ assert isinstance(out, str)
@@ -15,14 +15,18 @@ import pytest
15
15
 
16
16
  from cortexdb_mcp.config import CortexMCPConfig
17
17
  from cortexdb_mcp.insights import Insight, InsightsEngine, InsightType, Severity
18
+ from cortexdb_mcp.render import record_validity_text
18
19
  from cortexdb_mcp.server import (
19
20
  _belief_text,
20
21
  _config,
22
+ _conflict_lines,
21
23
  _fact_text,
22
24
  _is_synthetic_subject,
23
25
  _memories_from_context,
26
+ _record_text,
24
27
  _typed_value_text,
25
28
  advanced_search,
29
+ claim_history,
26
30
  entity_edges,
27
31
  entity_get,
28
32
  entity_link,
@@ -30,13 +34,16 @@ from cortexdb_mcp.server import (
30
34
  get_context,
31
35
  get_insights,
32
36
  health_check,
37
+ list_conflicts,
33
38
  memory_bulk_delete,
34
39
  memory_delete,
35
40
  memory_forget,
36
41
  memory_get,
37
42
  memory_list,
38
43
  memory_search,
44
+ memory_status,
39
45
  memory_store,
46
+ resolve_conflict,
40
47
  )
41
48
 
42
49
 
@@ -149,6 +156,55 @@ class TestMemoryStore:
149
156
  assert "observed_at" in body["context"]
150
157
  assert body["idempotency_key"].startswith("mcp-")
151
158
 
159
+ @pytest.mark.asyncio
160
+ async def test_memory_status_by_event_id(self):
161
+ patcher, mock = _mock_request(
162
+ {
163
+ "found": True,
164
+ "event_id": "evt_123",
165
+ "status": "indexed",
166
+ "stages_completed": ["captured", "indexed"],
167
+ "note": "stored and queryable",
168
+ }
169
+ )
170
+ with patcher:
171
+ result = await memory_status(event_id="evt_123")
172
+ assert "indexed" in result
173
+ assert "evt_123" in result
174
+ method, path = mock.call_args[0][:2]
175
+ assert method == "GET"
176
+ assert path == "/v1/experience/status"
177
+ assert mock.call_args[1]["params"] == {"event_id": "evt_123"}
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_memory_status_failed_says_restore_is_safe(self):
181
+ patcher, _ = _mock_request(
182
+ {
183
+ "found": True,
184
+ "event_id": "evt_bad",
185
+ "status": "failed",
186
+ "stages_completed": ["captured"],
187
+ "failure_reason": "embedding provider unavailable",
188
+ "note": "…",
189
+ }
190
+ )
191
+ with patcher:
192
+ result = await memory_status(idempotency_key="k-1")
193
+ assert "failed" in result
194
+ assert "re-store" in result.lower()
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_memory_status_not_found_is_safe_to_retry(self):
198
+ patcher, _ = _mock_request({"found": False, "note": "…"})
199
+ with patcher:
200
+ result = await memory_status(idempotency_key="never-sent")
201
+ assert "safe" in result.lower()
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_memory_status_requires_an_argument(self):
205
+ result = await memory_status()
206
+ assert "event_id" in result
207
+
152
208
  @pytest.mark.asyncio
153
209
  async def test_source_and_tags_become_labels(self):
154
210
  patcher, mock = _mock_request({"event_id": "evt_456"})
@@ -432,6 +488,252 @@ class TestEntities:
432
488
  assert "evt_link" in out
433
489
 
434
490
 
491
+ # ---------------------------------------------------------------------------
492
+ # Bi-temporal conflicts & claim history
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ def _record(
497
+ obj="Google",
498
+ *,
499
+ valid_from="2020-01-01T00:00:00Z",
500
+ vf_basis="stated",
501
+ valid_to=None,
502
+ vt_basis="stated",
503
+ status="active",
504
+ rid="0197a3a0-0000-7000-8000-00000000000a",
505
+ ):
506
+ return {
507
+ "id": rid,
508
+ "subject": "alice",
509
+ "predicate": "works_at",
510
+ "object": obj,
511
+ "redacted": False,
512
+ "valid_from": valid_from,
513
+ "valid_from_basis": vf_basis,
514
+ "valid_to": valid_to,
515
+ "valid_to_basis": vt_basis,
516
+ "recorded_from": "2026-06-15T12:00:00Z",
517
+ "recorded_to": None,
518
+ "status": status,
519
+ "supersedes": None,
520
+ "source_authority": "extraction",
521
+ "extractor": "llm",
522
+ "confidence": 0.9,
523
+ }
524
+
525
+
526
+ def _conflict(status="open", records=None, cid="0197a3a0-0000-7000-8000-0000000000c1"):
527
+ return {
528
+ "id": cid,
529
+ "scope": "org:test/user:test",
530
+ "subject": "alice",
531
+ "predicate": "works_at",
532
+ "kind": "value_disagreement",
533
+ "status": status,
534
+ "records": records if records is not None else [
535
+ _record("Google"),
536
+ _record("Meta", rid="0197a3a0-0000-7000-8000-00000000000b"),
537
+ ],
538
+ "detected_at": "2026-06-15T12:00:00Z",
539
+ "resolved_at": None,
540
+ "resolved_by": None,
541
+ "resolution": None,
542
+ }
543
+
544
+
545
+ class TestRecordRendering:
546
+ """Lock the BoundaryBasis rule: observed boundaries render as
547
+ "by <date>", never "on <date>" / "since <date>" (§2)."""
548
+
549
+ def test_stated_boundaries_render_since_until(self):
550
+ r = _record(valid_from="2020-01-01T00:00:00Z",
551
+ valid_to="2022-06-01T00:00:00Z")
552
+ out = record_validity_text(r)
553
+ assert out == "since 2020-01-01, until 2022-06-01"
554
+
555
+ def test_observed_valid_from_renders_by_never_since(self):
556
+ r = _record(vf_basis="observed")
557
+ out = record_validity_text(r)
558
+ assert "held by 2020-01-01" in out
559
+ assert "since" not in out
560
+ assert "on 2020" not in out
561
+
562
+ def test_observed_valid_to_renders_ended_by_never_until(self):
563
+ r = _record(valid_to="2022-06-01T00:00:00Z", vt_basis="observed")
564
+ out = record_validity_text(r)
565
+ assert "ended by 2022-06-01" in out
566
+ assert "until" not in out
567
+
568
+ def test_unbounded_is_undated(self):
569
+ assert record_validity_text(_record(valid_from=None)) == "undated"
570
+
571
+ def test_record_text_value_and_metadata(self):
572
+ out = _record_text(_record("Google"))
573
+ assert out.startswith("Google (")
574
+ assert "recorded 2026-06-15" in out
575
+ assert "conf 90%" in out
576
+ assert "active" not in out # only non-active statuses shown
577
+
578
+ def test_record_text_redacted(self):
579
+ r = _record("secret")
580
+ r["redacted"] = True
581
+ assert _record_text(r).startswith("[redacted]")
582
+
583
+ def test_conflict_lines_header_and_members(self):
584
+ lines = _conflict_lines(_conflict())
585
+ assert "[value_disagreement] alice works_at — open" in lines[0]
586
+ assert any("Google" in line for line in lines)
587
+ assert any("Meta" in line for line in lines)
588
+
589
+
590
+ class TestListConflicts:
591
+ @pytest.mark.asyncio
592
+ async def test_list_hits_v1_conflicts_with_scope(self):
593
+ patcher, mock = _mock_request({
594
+ "conflicts": [_conflict()], "total": 1, "next_cursor": None,
595
+ })
596
+ with patcher:
597
+ out = await list_conflicts()
598
+ method, path = mock.call_args[0][:2]
599
+ assert method == "GET"
600
+ assert path == "/v1/conflicts"
601
+ params = mock.call_args[1]["params"]
602
+ assert params["scope"] == "org:test/user:test"
603
+ assert params["status"] == "open"
604
+ assert "value_disagreement" in out
605
+ assert "Google" in out and "Meta" in out
606
+
607
+ @pytest.mark.asyncio
608
+ async def test_observed_basis_renders_by_in_tool_output(self):
609
+ c = _conflict(records=[_record("Google", vf_basis="observed")])
610
+ patcher, _ = _mock_request({"conflicts": [c], "total": 1, "next_cursor": None})
611
+ with patcher:
612
+ out = await list_conflicts()
613
+ assert "by 2020-01-01" in out
614
+ assert "since 2020-01-01" not in out
615
+
616
+ @pytest.mark.asyncio
617
+ async def test_empty_queue(self):
618
+ patcher, _ = _mock_request({"conflicts": [], "total": 0, "next_cursor": None})
619
+ with patcher:
620
+ out = await list_conflicts()
621
+ assert "No open conflicts" in out
622
+
623
+ @pytest.mark.asyncio
624
+ async def test_pagination_hint(self):
625
+ patcher, mock = _mock_request({
626
+ "conflicts": [_conflict()], "total": 5, "next_cursor": 1,
627
+ })
628
+ with patcher:
629
+ out = await list_conflicts(status="all", limit=1)
630
+ assert mock.call_args[1]["params"]["status"] == "all"
631
+ assert "cursor=1" in out
632
+
633
+
634
+ class TestResolveConflict:
635
+ @pytest.mark.asyncio
636
+ async def test_pick_posts_tagged_body(self):
637
+ patcher, mock = _mock_request(_conflict(status="manually_resolved"))
638
+ with patcher:
639
+ out = await resolve_conflict(
640
+ conflict_id="c1", action="pick",
641
+ winner="0197a3a0-0000-7000-8000-00000000000b",
642
+ reason="user confirmed",
643
+ )
644
+ method, path = mock.call_args[0][:2]
645
+ assert method == "POST"
646
+ assert path == "/v1/conflicts/c1/resolve"
647
+ body = mock.call_args[1]["json_body"]
648
+ assert body == {
649
+ "action": "pick",
650
+ "winner": "0197a3a0-0000-7000-8000-00000000000b",
651
+ "reason": "user confirmed",
652
+ }
653
+ assert "manually_resolved" in out
654
+
655
+ @pytest.mark.asyncio
656
+ async def test_split_requires_all_fields(self):
657
+ patcher, mock = _mock_request({})
658
+ with patcher:
659
+ out = await resolve_conflict(
660
+ conflict_id="c1", action="split", boundary="2024-03",
661
+ )
662
+ assert "Invalid resolution" in out
663
+ assert "earlier" in out
664
+ mock.assert_not_called() # nothing hit the wire
665
+
666
+ @pytest.mark.asyncio
667
+ async def test_unknown_action_and_extraneous_args(self):
668
+ patcher, mock = _mock_request({})
669
+ with patcher:
670
+ out1 = await resolve_conflict(conflict_id="c1", action="merge")
671
+ out2 = await resolve_conflict(
672
+ conflict_id="c1", action="dismiss", winner="r1",
673
+ )
674
+ assert "unknown action" in out1
675
+ assert "does not accept" in out2
676
+ mock.assert_not_called()
677
+
678
+ @pytest.mark.asyncio
679
+ async def test_dismiss_defaults_reason(self):
680
+ patcher, mock = _mock_request(_conflict(status="dismissed"))
681
+ with patcher:
682
+ await resolve_conflict(conflict_id="c1", action="dismiss")
683
+ assert mock.call_args[1]["json_body"] == {"action": "dismiss", "reason": ""}
684
+
685
+
686
+ class TestClaimHistory:
687
+ @pytest.mark.asyncio
688
+ async def test_history_hits_endpoint_and_renders_records(self):
689
+ patcher, mock = _mock_request({
690
+ "claim": {"scope": "org:test/user:test", "subject": "alice",
691
+ "predicate": "works_at"},
692
+ "history": [
693
+ _record("Google", valid_to="2022-06-01T00:00:00Z",
694
+ vt_basis="observed"),
695
+ _record("Meta", valid_from="2022-06-01T00:00:00Z",
696
+ vf_basis="observed",
697
+ rid="0197a3a0-0000-7000-8000-00000000000b"),
698
+ ],
699
+ "conflicts": [],
700
+ })
701
+ with patcher:
702
+ out = await claim_history(subject="Alice", predicate="works_at")
703
+ method, path = mock.call_args[0][:2]
704
+ assert method == "GET"
705
+ assert path == "/v1/claims/history"
706
+ params = mock.call_args[1]["params"]
707
+ assert params == {
708
+ "scope": "org:test/user:test",
709
+ "subject": "Alice",
710
+ "predicate": "works_at",
711
+ }
712
+ # Observed boundaries render as "by", never "since"/"until".
713
+ assert "ended by 2022-06-01" in out
714
+ assert "held by 2022-06-01" in out
715
+ assert "2 record(s)" in out
716
+
717
+ @pytest.mark.asyncio
718
+ async def test_history_lists_conflicts(self):
719
+ patcher, _ = _mock_request({
720
+ "claim": {"subject": "alice", "predicate": "works_at"},
721
+ "history": [_record()],
722
+ "conflicts": [_conflict()],
723
+ })
724
+ with patcher:
725
+ out = await claim_history(subject="alice", predicate="works_at")
726
+ assert "1 conflict(s)" in out
727
+ assert "value_disagreement" in out
728
+
729
+ @pytest.mark.asyncio
730
+ async def test_history_empty(self):
731
+ patcher, _ = _mock_request({"claim": {}, "history": [], "conflicts": []})
732
+ with patcher:
733
+ out = await claim_history(subject="nobody", predicate="none")
734
+ assert "No recorded history" in out
735
+
736
+
435
737
  # ---------------------------------------------------------------------------
436
738
  # Admin tools
437
739
  # ---------------------------------------------------------------------------
File without changes