cortexdb-mcp 0.7.4__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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexdb-mcp
3
- Version: 0.7.4
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
@@ -41,7 +41,7 @@ cortexdb-mcp --tool-profile code
41
41
  This materially reduces tool-schema context while retaining natural-language
42
42
  retrieval, typed traversal, prefix symbol discovery, and directory inventory.
43
43
 
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).
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.
45
45
 
46
46
  To target a custom deployment or pre-existing identity, set any of:
47
47
 
@@ -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
 
@@ -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