cortexdb-mcp 0.7.3__tar.gz → 0.7.5__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.
@@ -1,101 +1,109 @@
1
- # Rust
2
- /target
3
- **/*.rs.bk
4
-
5
- # Environment / secrets
6
- .env
7
- .env.local
8
- .env*.local
9
- *.pem
10
- *.key
11
- .npmrc
12
-
13
- # SQLite database
14
- *.sqlite
15
- *.sqlite-wal
16
- *.sqlite-shm
17
-
18
- # OS
19
- .DS_Store
20
- Thumbs.db
21
- desktop.ini
22
-
23
- # IDE
24
- .idea/
25
- .vscode/
26
- *.swp
27
- *.swo
28
-
29
- # Data directories
30
- cortexdb_data*/
31
- /data/
32
- # Per-bench tenant stores (RocksDB + Tantivy + HNSW state; regeneratable per run)
33
- /data_*/
34
- # Experimental per-branch stores (not tracked on this branch but left gitignored
35
- # so checkout from other branches doesn't surface them in git status)
36
- /event_memory_store/
37
- /llm_cache/
38
-
39
- # Benchmark inputs and per-run outputs (kept local, regenerated each run)
40
- benchmarks/longmemeval/data/
41
- benchmarks/longmemeval/server_results/
42
- benchmarks/longmemeval/fast_results/
43
- benchmarks/longmemeval/micro_results/
44
- benchmarks/longmemeval/server_logs/
45
- benchmarks/longmemeval/*.log
46
- benchmarks/locomo/locomo_results*.json
47
- benchmarks/locomo/server_results/
48
- benchmarks/locomo/*.log
49
- /answer_out.json
50
-
51
- # Local Claude Code state
52
- .claude/
53
- .tmp/
54
-
55
- # Python
56
- __pycache__/
57
- *.pyc
58
- .venv/
59
- venv/
60
-
61
- # Node
62
- node_modules/
63
- dist/
64
- .next/
65
-
66
- # Egg info
67
- *.egg-info/
68
-
69
- # Scratch/debug text files at root
70
- /*.txt
71
- /*.log
72
-
73
- # Local debug / marketing / private content (not for repo)
74
- harness/.reports/
75
- harness_data_*/
76
- blog/
77
- sales/
78
- videos/
79
- local-instance/
80
-
81
- # doc-claims verifier scratch output
82
- tools/verifier_out*/
83
-
84
- # Generated benchmark / runtime data (audit M-10: hundreds of untracked
85
- # data dirs at the workspace root slow every git status/search and risk
86
- # being packaged; results JSONs are artifacts, never source)
87
- benchmarks/data_*/
88
- benchmarks/**/fast_results/
89
- data_*/
90
- *_data/
91
- *_data_20*/
92
- cortexdb_data*/
93
- server_results/
94
- *.log
95
- docker_build.log
96
-
97
- # local gate/bench data stores
98
- gate*_data_*/
99
-
100
- # Code-plane benchmark local run outputs (official results are committed deliberately)
101
- benchmarks/codeplane/runs/
1
+ # Rust
2
+ /target
3
+ **/*.rs.bk
4
+
5
+ # Environment / secrets
6
+ .env
7
+ .env.local
8
+ .env*.local
9
+ *.pem
10
+ *.key
11
+ .npmrc
12
+
13
+ # SQLite database
14
+ *.sqlite
15
+ *.sqlite-wal
16
+ *.sqlite-shm
17
+
18
+ # OS
19
+ .DS_Store
20
+ Thumbs.db
21
+ desktop.ini
22
+
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ *.swo
28
+
29
+ # Data directories
30
+ cortexdb_data*/
31
+ /data/
32
+ # Per-bench tenant stores (RocksDB + Tantivy + HNSW state; regeneratable per run)
33
+ /data_*/
34
+ # Experimental per-branch stores (not tracked on this branch but left gitignored
35
+ # so checkout from other branches doesn't surface them in git status)
36
+ /event_memory_store/
37
+ /llm_cache/
38
+
39
+ # Benchmark inputs and per-run outputs (kept local, regenerated each run)
40
+ benchmarks/longmemeval/data/
41
+ benchmarks/longmemeval/server_results/
42
+ benchmarks/longmemeval/fast_results/
43
+ benchmarks/longmemeval/micro_results/
44
+ benchmarks/longmemeval/server_logs/
45
+ benchmarks/longmemeval/*.log
46
+ benchmarks/locomo/locomo_results*.json
47
+ benchmarks/locomo/server_results/
48
+ benchmarks/locomo/*.log
49
+ /answer_out.json
50
+
51
+ # Local Claude Code state
52
+ .claude/
53
+ .tmp/
54
+
55
+ # Python
56
+ __pycache__/
57
+ *.pyc
58
+ .venv/
59
+ venv/
60
+
61
+ # Node
62
+ node_modules/
63
+ dist/
64
+ .next/
65
+
66
+ # Egg info
67
+ *.egg-info/
68
+
69
+ # Scratch/debug text files at root
70
+ /*.txt
71
+ /*.log
72
+
73
+ # Local debug / marketing / private content (not for repo)
74
+ harness/.reports/
75
+ harness_data_*/
76
+ blog/
77
+ sales/
78
+ videos/
79
+ local-instance/
80
+
81
+ # doc-claims verifier scratch output
82
+ tools/verifier_out*/
83
+
84
+ # Generated benchmark / runtime data (audit M-10: hundreds of untracked
85
+ # data dirs at the workspace root slow every git status/search and risk
86
+ # being packaged; results JSONs are artifacts, never source)
87
+ benchmarks/data_*/
88
+ benchmarks/**/fast_results/
89
+ data_*/
90
+ *_data/
91
+ *_data_20*/
92
+ cortexdb_data*/
93
+ server_results/
94
+ *.log
95
+ docker_build.log
96
+
97
+ # local gate/bench data stores
98
+ gate*_data_*/
99
+
100
+ # Code-plane benchmark local run outputs (official results are committed deliberately)
101
+ benchmarks/codeplane/runs/
102
+
103
+ # Deliberately untracked, four times over: `git add docs/` keeps sweeping the
104
+ # investor deck sources and the 2.7 MB OpenClaw design PDF back in (7dda2a51,
105
+ # 3a778b5e, then again in the 2026-09-09 checkpoint). They live on disk; they do
106
+ # not belong in the repo. Stage explicit paths, never a directory.
107
+ docs/investor_deck/
108
+ docs/CORTEX_OPENCLAW_AGENT_OS_DESIGN.md
109
+ docs/CORTEX_OPENCLAW_AGENT_OS_DESIGN.pdf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexdb-mcp
3
- Version: 0.7.3
3
+ Version: 0.7.5
4
4
  Summary: MCP Server for CortexDB — expose memory operations to AI agents
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -9,6 +9,10 @@ Requires-Dist: httpx>=0.27
9
9
  Requires-Dist: mcp<2.0,>=1.0
10
10
  Requires-Dist: pydantic>=2.0
11
11
  Requires-Dist: uvicorn>=0.29
12
+ Provides-Extra: test
13
+ Requires-Dist: anyio<5,>=4.12; extra == 'test'
14
+ Requires-Dist: pytest-asyncio<2,>=1.3; extra == 'test'
15
+ Requires-Dist: pytest<10,>=9.0; extra == 'test'
12
16
  Description-Content-Type: text/markdown
13
17
 
14
18
  # CortexDB MCP Server
@@ -37,7 +41,7 @@ cortexdb-mcp --tool-profile code
37
41
  This materially reduces tool-schema context while retaining natural-language
38
42
  retrieval, typed traversal, prefix symbol discovery, and directory inventory.
39
43
 
40
- That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days).
44
+ That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days); the cached `expires_at` is checked at load, and a token the server rejects with 401 is discarded and re-minted automatically, so no hand-deletion of `state.json` is needed. Signup runs against any configured `CORTEXDB_URL`, self-hosted included; a deployment with no minter answers 503 and the server carries on unauthenticated.
41
45
 
42
46
  To target a custom deployment or pre-existing identity, set any of:
43
47
 
@@ -171,7 +175,7 @@ On Windows, MCP clients sometimes need the absolute path:
171
175
  | `memory_search` | `POST /v1/recall` | Search memories using natural language. |
172
176
  | `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. Accepts `from_preview_id` from `forget_preview`. |
173
177
  | `forget_preview` | `POST /v1/forget/preview` | Non-destructive dry run of a forget: per-layer estimated deletion counts + a `preview_id` for the safe two-phase flow. |
174
- | `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
178
+ | `get_context` | `POST /v1/recall` (configured view; holistic by default) | By default, deep context across the requested scope, authorized ancestors, and authorized descendants—never siblings. |
175
179
  | `advanced_search` | `POST /v1/recall` + temporal | Search with structured filters (time / source / type). |
176
180
 
177
181
  ### Event CRUD
@@ -222,12 +226,8 @@ Resources provide read-only data that AI tools can access:
222
226
  | Resource URI | Description |
223
227
  |---|---|
224
228
  | `cortexdb://health` | Server health status |
225
- | `cortexdb://metrics` | Request metrics (total, active, errors, rate-limited) |
226
- | `cortexdb://usage` | Usage statistics and tier limits |
227
- | `cortexdb://episodes` | Recent 50 episodes |
228
- | `cortexdb://entities` | Top 100 knowledge graph entities |
229
+ | `cortexdb://episodes` | Recent 50 events in the default scope |
229
230
  | `cortexdb://insights` | Proactive insights |
230
- | `cortexdb://ontology` | Entity and relationship type schema |
231
231
 
232
232
  ## Prompts
233
233
 
@@ -247,6 +247,10 @@ Pre-built prompt templates:
247
247
  |---|---|---|
248
248
  | `CORTEXDB_URL` | `https://api-v1.cortexdb.ai` | CortexDB server URL |
249
249
  | `CORTEXDB_API_KEY` | (none) | API key for authentication |
250
+ | `CORTEXDB_ACTOR` | (from signup state) | Actor for `X-Cortex-Actor`; must match the token subject |
251
+ | `CORTEXDB_SCOPE` | (from signup state) | Default scope for tool calls |
252
+ | `CORTEXDB_VIEW` | `holistic` | Recall reach: `holistic` = self + authorized ancestors + authorized descendants (no siblings); `descend` = self + authorized descendants; `granular` = exact scope |
253
+ | `CORTEXDB_TENANT_ID` | (none) | Legacy v0 tenant identifier |
250
254
  | `CORTEXDB_TIMEOUT` | `30.0` | HTTP request timeout (seconds) |
251
255
 
252
256
  ## Examples
@@ -24,7 +24,7 @@ cortexdb-mcp --tool-profile code
24
24
  This materially reduces tool-schema context while retaining natural-language
25
25
  retrieval, typed traversal, prefix symbol discovery, and directory inventory.
26
26
 
27
- That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days).
27
+ That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days); the cached `expires_at` is checked at load, and a token the server rejects with 401 is discarded and re-minted automatically, so no hand-deletion of `state.json` is needed. Signup runs against any configured `CORTEXDB_URL`, self-hosted included; a deployment with no minter answers 503 and the server carries on unauthenticated.
28
28
 
29
29
  To target a custom deployment or pre-existing identity, set any of:
30
30
 
@@ -158,7 +158,7 @@ On Windows, MCP clients sometimes need the absolute path:
158
158
  | `memory_search` | `POST /v1/recall` | Search memories using natural language. |
159
159
  | `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. Accepts `from_preview_id` from `forget_preview`. |
160
160
  | `forget_preview` | `POST /v1/forget/preview` | Non-destructive dry run of a forget: per-layer estimated deletion counts + a `preview_id` for the safe two-phase flow. |
161
- | `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
161
+ | `get_context` | `POST /v1/recall` (configured view; holistic by default) | By default, deep context across the requested scope, authorized ancestors, and authorized descendants—never siblings. |
162
162
  | `advanced_search` | `POST /v1/recall` + temporal | Search with structured filters (time / source / type). |
163
163
 
164
164
  ### Event CRUD
@@ -209,12 +209,8 @@ Resources provide read-only data that AI tools can access:
209
209
  | Resource URI | Description |
210
210
  |---|---|
211
211
  | `cortexdb://health` | Server health status |
212
- | `cortexdb://metrics` | Request metrics (total, active, errors, rate-limited) |
213
- | `cortexdb://usage` | Usage statistics and tier limits |
214
- | `cortexdb://episodes` | Recent 50 episodes |
215
- | `cortexdb://entities` | Top 100 knowledge graph entities |
212
+ | `cortexdb://episodes` | Recent 50 events in the default scope |
216
213
  | `cortexdb://insights` | Proactive insights |
217
- | `cortexdb://ontology` | Entity and relationship type schema |
218
214
 
219
215
  ## Prompts
220
216
 
@@ -234,6 +230,10 @@ Pre-built prompt templates:
234
230
  |---|---|---|
235
231
  | `CORTEXDB_URL` | `https://api-v1.cortexdb.ai` | CortexDB server URL |
236
232
  | `CORTEXDB_API_KEY` | (none) | API key for authentication |
233
+ | `CORTEXDB_ACTOR` | (from signup state) | Actor for `X-Cortex-Actor`; must match the token subject |
234
+ | `CORTEXDB_SCOPE` | (from signup state) | Default scope for tool calls |
235
+ | `CORTEXDB_VIEW` | `holistic` | Recall reach: `holistic` = self + authorized ancestors + authorized descendants (no siblings); `descend` = self + authorized descendants; `granular` = exact scope |
236
+ | `CORTEXDB_TENANT_ID` | (none) | Legacy v0 tenant identifier |
237
237
  | `CORTEXDB_TIMEOUT` | `30.0` | HTTP request timeout (seconds) |
238
238
 
239
239
  ## Examples
@@ -76,6 +76,10 @@ PATH_ALIASES = {
76
76
  "/recall": "/v1/recall",
77
77
  }
78
78
 
79
+ #: The fields of `ForgetRequest.selector` on the wire. They are NESTED under
80
+ #: `selector`; the route rejects them at the top level with 422 INVALID_BODY.
81
+ _SELECTOR_KEYS = ("memory_ids", "about_subject", "about_entity", "predicate")
82
+
79
83
  #: SDK methods, mirroring the guidance the SDKs themselves now raise.
80
84
  SDK_METHODS = {
81
85
  "experience", "experience_bulk", "recall", "answer", "compose", "forget",
@@ -142,6 +146,7 @@ def check_http(method: str, path: str, body: dict[str, Any] | None = None) -> Ve
142
146
  path = path.split("?", 1)[0]
143
147
  path = "/" + path.strip("/") if path else ""
144
148
  call = f"{method} {path}"
149
+ has_body = bool(body)
145
150
  body = body or {}
146
151
 
147
152
  if (method, path) not in ROUTES:
@@ -181,14 +186,36 @@ def check_http(method: str, path: str, body: dict[str, Any] | None = None) -> Ve
181
186
  problems.extend(_check_scope(body["scope"]))
182
187
  # The one cross-field rule the server enforces and agents keep hitting.
183
188
  if path.startswith("/v1/forget"):
184
- selectors = [k for k in ("memory_ids", "about_subject", "about_entity", "predicate")
185
- if body.get(k)]
189
+ # Selectors live UNDER `selector` on the wire (ForgetRequest.selector),
190
+ # never at the top level. Reading only the top level meant this rule
191
+ # never fired for a real body -- which is how the MCP server's own
192
+ # memory_delete shipped a body the validator called valid and the
193
+ # server refused with 400 on every call (M1).
194
+ selector = body.get("selector")
195
+ selector = selector if isinstance(selector, dict) else {}
196
+ selectors = [k for k in _SELECTOR_KEYS if selector.get(k)]
197
+ misplaced = [k for k in _SELECTOR_KEYS if body.get(k)]
198
+ if misplaced:
199
+ problems.append(
200
+ f"{', '.join(misplaced)} must live under `selector`, not at the "
201
+ f"top level. The route rejects unknown top-level fields with "
202
+ f"422 INVALID_BODY (expected: scope, layers, selector, cascade, "
203
+ f"confirm_all, audit_note/reason, from_preview_id)."
204
+ )
186
205
  if selectors and body.get("confirm_all"):
187
206
  problems.append(
188
207
  f"selector ({', '.join(selectors)}) combined with confirm_all=true is "
189
208
  f"refused (AMBIGUOUS_SELECTOR_CONFIRM_ALL). confirm_all authorizes a "
190
- f"SCOPE-WIDE erase; a selector narrows it. Use confirm_all=false with "
191
- f"the selector, or drop the selector for a scope-wide forget."
209
+ f"SCOPE-WIDE erase; a selector narrows it. Drop confirm_all to delete "
210
+ f"exactly the selected memories, or drop the selector for a "
211
+ f"scope-wide forget."
212
+ )
213
+ if has_body and not selectors and not misplaced and not body.get("confirm_all"):
214
+ problems.append(
215
+ "no selector and no confirm_all: the route refuses an empty "
216
+ "selector without confirmation (422 "
217
+ "EMPTY_SELECTOR_WITHOUT_CONFIRMATION). Name what to delete "
218
+ "under `selector`, or set confirm_all=true for the whole scope."
192
219
  )
193
220
  notes = [n for n in [spec.get("notes")] if n]
194
221
  return Verdict(valid=not problems, call=call, problems=problems, notes=notes)
@@ -0,0 +1,278 @@
1
+ """Configuration management for the CortexDB MCP server.
2
+
3
+ Reads settings from environment variables with sensible defaults.
4
+
5
+ The MCP server targets the v1 API surface by default
6
+ (``https://api-v1.cortexdb.ai``). Three auth paths are supported:
7
+
8
+ 1. **Anonymous one-click** — leave ``CORTEXDB_API_KEY`` unset and the server
9
+ will call ``POST /v1/auth/signup`` on first launch (against ANY configured
10
+ URL, self-hosted included), caching the resulting PASETO token, actor,
11
+ scope and ``expires_at`` under ``~/.config/cortexdb-mcp/state.json``.
12
+ Re-launches reuse the cached token until it expires: a lapsed cache is
13
+ treated as absent, and a token the server rejects with 401 is discarded and
14
+ re-minted once, so a stale ``state.json`` never has to be deleted by hand.
15
+
16
+ 2. **Bring your own PASETO** — set ``CORTEXDB_API_KEY`` to a PASETO ``v4.public.*``
17
+ token. The server still needs ``CORTEXDB_ACTOR`` and ``CORTEXDB_SCOPE`` so
18
+ it can send the matching ``X-Cortex-Actor`` header.
19
+
20
+ 3. **Static operator key** — set ``CORTEXDB_API_KEY`` to a deployment's
21
+ ``CORTEX_API_KEY``. Such a key does not expire, and is never discarded or
22
+ re-minted on a 401; the error surfaces to the caller instead. (The older
23
+ ``cx_live_`` / Turso customer-key path is retired server-side.)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ #: Refresh a cached anonymous token this long before its stated expiry, so a
35
+ #: call started just under the wire doesn't 401 mid-flight.
36
+ EXPIRY_SKEW_SECONDS = 300
37
+
38
+ #: A token whose remaining life exceeds this is treated as non-expiring.
39
+ #: Auth-disabled / static-API-key deployments synthesize ``exp = i64::MAX / 2``
40
+ #: (~1.46e11 years), which rendered as a literal 4611686016638011224-second
41
+ #: countdown. Anything past a century is a sentinel, not a deadline.
42
+ NON_EXPIRING_THRESHOLD_SECONDS = 100 * 365 * 24 * 3600
43
+
44
+
45
+ def parse_expiry(value: str | None) -> datetime | None:
46
+ """Parse an RFC3339 expiry into an aware UTC datetime, or None.
47
+
48
+ Returns None for absent, empty, or unparseable input — the server sends an
49
+ EMPTY ``x-cortex-token-expires-at`` for non-expiring credentials, because
50
+ its sentinel ``exp`` overflows ``DateTime::from_timestamp``.
51
+ """
52
+ if not value:
53
+ return None
54
+ text = value.strip()
55
+ if not text:
56
+ return None
57
+ if text.endswith(("Z", "z")):
58
+ text = text[:-1] + "+00:00"
59
+ try:
60
+ parsed = datetime.fromisoformat(text)
61
+ except ValueError:
62
+ return None
63
+ if parsed.tzinfo is None:
64
+ parsed = parsed.replace(tzinfo=timezone.utc)
65
+ return parsed.astimezone(timezone.utc)
66
+
67
+
68
+ def expiry_is_sentinel(seconds: float) -> bool:
69
+ """Is this ``expires_in`` a non-expiry sentinel rather than a deadline?"""
70
+ return seconds >= NON_EXPIRING_THRESHOLD_SECONDS
71
+
72
+
73
+ def _state_path() -> Path:
74
+ """Where the cached anonymous-signup token lives.
75
+
76
+ Uses XDG_CONFIG_HOME on Linux/macOS, %APPDATA% on Windows, with a
77
+ sensible fallback for both.
78
+ """
79
+ if os.name == "nt":
80
+ base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
81
+ else:
82
+ base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
83
+ return base / "cortexdb-mcp" / "state.json"
84
+
85
+
86
+ @dataclass
87
+ class CortexMCPConfig:
88
+ """Configuration for connecting to a CortexDB instance.
89
+
90
+ Attributes:
91
+ url: Base URL of the CortexDB HTTP API.
92
+ api_key: PASETO bearer token (or legacy cx_live_ key in v0 mode).
93
+ actor: ActorId sent as the X-Cortex-Actor header. Required for v1.
94
+ scope: Default scope path for write/recall calls. Required for v1.
95
+ tenant_id: Legacy tenant id for v0 calls. Defaults to None.
96
+ expires_at: RFC3339 expiry of ``api_key`` when the mint reported one.
97
+ anonymous: Whether ``api_key`` was minted by anonymous signup.
98
+ timeout: HTTP request timeout in seconds.
99
+ """
100
+
101
+ url: str = "https://api-v1.cortexdb.ai"
102
+ api_key: str | None = None
103
+ actor: str | None = None
104
+ scope: str | None = None
105
+ tenant_id: str | None = None
106
+ # RFC3339 expiry of ``api_key``, when known (anonymous signup reports one).
107
+ # None means "unknown / does not expire", never "already expired".
108
+ expires_at: str | None = None
109
+ # True when ``api_key`` came from an anonymous signup (this process or a
110
+ # cached one), so the server may discard and re-mint it on rejection. An
111
+ # operator-supplied CORTEXDB_API_KEY is never discarded — we surface the
112
+ # 401 instead of silently swapping identities underneath them.
113
+ anonymous: bool = False
114
+ # Default public-recall reach. "holistic" = the scope + authorized ancestors
115
+ # + authorized descendants, never siblings (the server default); "descend"
116
+ # = the scope + authorized descendants; "granular" = exact scope.
117
+ view: str = "holistic"
118
+ timeout: float = 30.0
119
+
120
+ # Set by save_state() / from_env() when an anonymous signup happens; used
121
+ # by the server to indicate whether persistence is enabled. Kept off the
122
+ # dataclass init signature so tests can construct configs directly.
123
+ state_file: Path = field(default_factory=_state_path)
124
+
125
+ @classmethod
126
+ def from_env(cls) -> "CortexMCPConfig":
127
+ """Build configuration from environment variables.
128
+
129
+ Environment variables
130
+ ---------------------
131
+ CORTEXDB_URL -- API base URL (default ``https://api-v1.cortexdb.ai``).
132
+ CORTEXDB_API_KEY -- PASETO token (preferred) or legacy v0 key.
133
+ CORTEXDB_ACTOR -- ActorId for X-Cortex-Actor (e.g. ``user:alice``).
134
+ CORTEXDB_SCOPE -- Default scope path for tool calls.
135
+ CORTEXDB_VIEW -- Default public-recall reach: ``holistic``
136
+ (scope + authorized ancestors + authorized
137
+ descendants, never siblings), ``descend``
138
+ (scope + authorized descendants), or ``granular``.
139
+ CORTEXDB_TENANT_ID -- Legacy tenant id (v0 callers only).
140
+ CORTEXDB_TIMEOUT -- Request timeout in seconds (default ``30.0``).
141
+
142
+ When CORTEXDB_API_KEY is unset, the server will look for a cached
143
+ state file written by a previous anonymous signup; failing that,
144
+ the next outgoing request triggers a fresh ``/v1/auth/signup``.
145
+ """
146
+ url = os.environ.get("CORTEXDB_URL", cls.url)
147
+ cfg = cls(
148
+ url=url,
149
+ api_key=os.environ.get("CORTEXDB_API_KEY"),
150
+ actor=os.environ.get("CORTEXDB_ACTOR"),
151
+ scope=os.environ.get("CORTEXDB_SCOPE"),
152
+ tenant_id=os.environ.get("CORTEXDB_TENANT_ID"),
153
+ view=os.environ.get("CORTEXDB_VIEW", cls.view),
154
+ timeout=float(os.environ.get("CORTEXDB_TIMEOUT", str(cls.timeout))),
155
+ )
156
+
157
+ # If env didn't provide credentials, hydrate from the on-disk cache
158
+ # (anonymous signups from prior MCP-server launches). The cache only
159
+ # applies when CORTEXDB_API_KEY is unset, so an explicit env key
160
+ # always wins. We call `_state_path()` here (vs. reading
161
+ # cfg.state_file) so tests can monkeypatch the module-level
162
+ # function and influence resolution.
163
+ cfg.state_file = _state_path()
164
+ if cfg.api_key is None:
165
+ cached = _load_state(cfg.state_file)
166
+ # M4: an expired cached token used to be reused until every call
167
+ # 401'd, with "delete state.json" as the only recovery. Treat a
168
+ # lapsed (or nearly lapsed) cache as absent so the next request
169
+ # mints a fresh identity instead.
170
+ if cached and cached_token_is_usable(cached):
171
+ cfg.api_key = cached.get("token")
172
+ cfg.actor = cfg.actor or cached.get("actor")
173
+ cfg.scope = cfg.scope or cached.get("scope")
174
+ cfg.expires_at = cached.get("expires_at")
175
+ cfg.anonymous = True
176
+ return cfg
177
+
178
+ def token_is_expiring(self, *, now: datetime | None = None) -> bool:
179
+ """Is ``api_key`` past (or within the skew window of) its expiry?
180
+
181
+ False when no expiry is known — an unknown expiry is not a lapsed one.
182
+ """
183
+ return not expiry_is_future(self.expires_at, now=now)
184
+
185
+ def adopt_signup(
186
+ self, token: str, actor: str, scope: str, expires_at: str | None
187
+ ) -> None:
188
+ """Install a freshly minted anonymous identity and cache it."""
189
+ self.api_key = token
190
+ self.actor = actor
191
+ self.scope = scope
192
+ self.expires_at = expires_at
193
+ self.anonymous = True
194
+ self.save_state(token, actor, scope, expires_at)
195
+
196
+ def forget_credentials(self) -> None:
197
+ """Drop an anonymous identity the server rejected, in memory and on
198
+ disk, so the next request signs up again rather than replaying a dead
199
+ token. A no-op for operator-supplied keys: we never discard those."""
200
+ if not self.anonymous:
201
+ return
202
+ self.api_key = None
203
+ self.expires_at = None
204
+ self.anonymous = False
205
+ try:
206
+ self.state_file.unlink(missing_ok=True)
207
+ except OSError:
208
+ pass
209
+
210
+ def save_state(
211
+ self, token: str, actor: str, scope: str, expires_at: str | None = None
212
+ ) -> None:
213
+ """Persist an anonymous-signup outcome so subsequent launches reuse
214
+ the same identity. Writes are best-effort — failures (perm denied,
215
+ full disk, etc.) are silently swallowed because the server is still
216
+ functional with an in-memory token."""
217
+ try:
218
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
219
+ self.state_file.write_text(
220
+ json.dumps(
221
+ {
222
+ "token": token,
223
+ "actor": actor,
224
+ "scope": scope,
225
+ # M4: without this the cache had no way to know the
226
+ # token had lapsed.
227
+ **({"expires_at": expires_at} if expires_at else {}),
228
+ },
229
+ indent=2,
230
+ ),
231
+ encoding="utf-8",
232
+ )
233
+ # Best-effort tighten permissions on the secret-bearing file.
234
+ try:
235
+ os.chmod(self.state_file, 0o600)
236
+ except OSError:
237
+ pass
238
+ except OSError:
239
+ pass
240
+
241
+
242
+ def expiry_is_future(expires_at: str | None, *, now: datetime | None = None) -> bool:
243
+ """Does ``expires_at`` leave more than the skew window of useful life?
244
+
245
+ An absent / unparseable expiry counts as usable: it means "unknown", and a
246
+ non-expiring credential reports exactly that. Only a parseable expiry that
247
+ has (nearly) arrived is a refusal.
248
+ """
249
+ deadline = parse_expiry(expires_at)
250
+ if deadline is None:
251
+ return True
252
+ moment = now or datetime.now(timezone.utc)
253
+ if moment.tzinfo is None:
254
+ moment = moment.replace(tzinfo=timezone.utc)
255
+ return (deadline - moment).total_seconds() > EXPIRY_SKEW_SECONDS
256
+
257
+
258
+ def cached_token_is_usable(
259
+ cached: dict[str, str], *, now: datetime | None = None
260
+ ) -> bool:
261
+ """Is a cached signup state still worth replaying?"""
262
+ if not cached.get("token"):
263
+ return False
264
+ return expiry_is_future(cached.get("expires_at"), now=now)
265
+
266
+
267
+ def _load_state(path: Path) -> dict[str, str] | None:
268
+ """Read a cached signup state. Returns None on any error — the caller
269
+ falls back to env-only config."""
270
+ try:
271
+ if not path.exists():
272
+ return None
273
+ data = json.loads(path.read_text(encoding="utf-8"))
274
+ if isinstance(data, dict) and "token" in data:
275
+ return {k: str(v) for k, v in data.items() if isinstance(v, str)}
276
+ except (OSError, json.JSONDecodeError):
277
+ pass
278
+ return None
@@ -4,10 +4,14 @@ Single source of truth for projecting the v1 wire shapes into readable text,
4
4
  shared by the MCP tools (``server.py``) and the insights engine
5
5
  (``insights.py``).
6
6
 
7
- Ground truth (verified against a live ``/v1/recall``): the StratifiedPack
8
- returns recalled *events* inside ``context_block`` (NOT ``layers.events`` —
9
- that layer is empty on the synthesized-recall path), while ``layers`` carries
10
- the derived ``facts`` / ``beliefs`` / ``episodes``. A v1 Fact serializes its
7
+ Ground truth (re-verified against a live ``/v1/recall`` on v0.9.10): the
8
+ StratifiedPack renders recalled *events* into ``context_block`` AND returns
9
+ them structurally in ``layers.events`` (with ``context.labels`` and
10
+ ``context.observed_at``) — an older note here claimed that layer was always
11
+ empty on the synthesized-recall path, which is no longer true and cost
12
+ ``advanced_search`` its label filters. ``layers`` also carries the derived
13
+ ``facts`` / ``beliefs`` / ``episodes``. ``memories_from_context`` remains the
14
+ fallback for packs that carry a context block and no events layer. A v1 Fact serializes its
11
15
  triple FLAT — ``subject`` / ``predicate`` / ``object`` are top-level keys, with
12
16
  no ``triple`` wrapper — and ``subject`` / ``object`` are tagged ``TypedValue``
13
17
  objects: ``{"type":"entity","id":...,"name":...}`` or