sibyllabs commited on
Commit
4e4636d
·
1 Parent(s): b605fc1

release: client 0.4.4 + mcp 0.1.3 — beta bug-fix remediation

Browse files

client 0.4.4:
- FTS5 search: drop AND/OR/NOT/NEAR operator keywords during tokenization
so natural-language queries return results instead of ~0 hits
- validate_identifier rejects '..' traversal + shell metachars (defense-in-depth);
SQL was already parameterized, this guards downstream non-parameterized consumers

mcp 0.1.3:
- first-use writes no longer fail pre-activation: tenant_id falls back to
DEFAULT_TENANT instead of forcing NULL (reads + tool discovery worked, so a
broken install looked healthy); regression test added
- __version__ single-sourced from importlib.metadata (no more drift)
- pin bumped to sibyl-memory-client>=0.4.4

sibyl-memory-client/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to `sibyl-memory-client` are recorded here. Format
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
5
  follows [SemVer](https://semver.org/).
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## [0.4.3] - 2026-05-26
8
 
9
  ### Fixed
 
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning
5
  follows [SemVer](https://semver.org/).
6
 
7
+ ## [0.4.4] - 2026-05-28
8
+
9
+ Beta-tester bug-report remediation (chainriffs Discord + KAPPA rounds 3/4).
10
+
11
+ ### Fixed
12
+
13
+ - **FTS5 search: uppercase operator keywords poisoned recall.** A
14
+ natural-language query containing `AND` / `OR` / `NOT` / `NEAR`
15
+ (e.g. `"auth AND db"`, `"cache NEAR eviction"`) had each token
16
+ phrase-quoted into a *required literal* term, so a matched row had to
17
+ literally contain the word "AND"/"NEAR" — recall silently collapsed to
18
+ ~0 hits. These keywords are now dropped during tokenization so the
19
+ remaining terms AND together (the natural intent). A query that is
20
+ *only* operator keywords keeps them as literals so searching for the
21
+ word "and" still resolves. (`_drop_fts5_operator_tokens`.)
22
+
23
+ ### Security
24
+
25
+ - **Identifier validation: path-traversal + metacharacter defense-in-depth**
26
+ (KAPPA #3 PARTIAL). `validate_identifier` now rejects the `..` traversal
27
+ marker and the shell/redirection/quote metacharacters `< > | ; " \``. SQL
28
+ was already parameterized; this guards downstream non-parameterized
29
+ consumers (filesystem export, CLI display, logs). Apostrophe is
30
+ deliberately allowed (legit in name-shaped keys). Bare `/` and `\` remain
31
+ allowed per the v0.4.0 contract — rejecting raw separators is a contract
32
+ change flagged for team decision.
33
+
34
  ## [0.4.3] - 2026-05-26
35
 
36
  ### Fixed
sibyl-memory-client/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "sibyl-memory-client"
7
- version = "0.4.3"
8
  description = "Local-first agentic memory SDK. SQLite-backed five-tier hierarchical schema, FTS5 search, multi-tenant, with self-learning skill detection and local memory linter. Foundation of the Sibyl Memory Plugin family."
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
  license = { text = "MIT" }
 
4
 
5
  [project]
6
  name = "sibyl-memory-client"
7
+ version = "0.4.4"
8
  description = "Local-first agentic memory SDK. SQLite-backed five-tier hierarchical schema, FTS5 search, multi-tenant, with self-learning skill detection and local memory linter. Foundation of the Sibyl Memory Plugin family."
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
  license = { text = "MIT" }
sibyl-memory-client/src/sibyl_memory_client/client.py CHANGED
@@ -34,6 +34,22 @@ _IDENT_MAX_LENGTH = 1024
34
  # design: identifiers are short single-line strings, not arbitrary payloads.
35
  _IDENT_FORBIDDEN_CODE_POINTS = frozenset(range(0, 0x20)) | {0x7F}
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def validate_identifier(value: Any, *, field_name: str) -> str:
39
  """Validate a user-supplied identifier (entity name, state key, etc.).
@@ -72,6 +88,25 @@ def validate_identifier(value: Any, *, field_name: str) -> str:
72
  f"Remove control characters / null bytes / tabs / newlines."
73
  ),
74
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  return value
76
 
77
 
@@ -146,6 +181,23 @@ _FTS5_COLUMN_TOKENS = frozenset({"name", "category", "body", "tenant_id",
146
  "payload", "ts", "rowid"})
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def _sanitize_fts5_query(raw: str, *, prefix: bool = False, as_phrase: bool = False) -> str:
150
  """Wrap a user query as a safe FTS5 MATCH expression.
151
 
@@ -196,6 +248,7 @@ def _sanitize_fts5_query(raw: str, *, prefix: bool = False, as_phrase: bool = Fa
196
  tokens = [t for t in cleaned.split() if t]
197
  if not tokens:
198
  return ""
 
199
  if len(tokens) == 1:
200
  return f"{tokens[0]}*"
201
  # Multiple tokens: all earlier tokens are literal, the last gets `*`.
@@ -217,6 +270,7 @@ def _sanitize_fts5_query(raw: str, *, prefix: bool = False, as_phrase: bool = Fa
217
  # query still has SOME defensible shape rather than empty.
218
  escaped = s.replace('"', '""')
219
  return f'"{escaped}"'
 
220
  return " ".join(f'"{t}"' for t in tokens)
221
 
222
  # The default tenant for single-user local installs.
 
34
  # design: identifiers are short single-line strings, not arbitrary payloads.
35
  _IDENT_FORBIDDEN_CODE_POINTS = frozenset(range(0, 0x20)) | {0x7F}
36
 
37
+ # v0.4.4 (KAPPA #3 defense-in-depth): SQL is parameterized so injection is
38
+ # closed at the DB, but identifiers flow into consumers that do NOT parameterize
39
+ # -- filesystem export (a `name` becomes a path component), CLI display, log
40
+ # lines, future per-entity backends. Reject path-traversal shapes and the
41
+ # shell/redirection/quote metacharacters that have no place in a short flat key.
42
+ # Apostrophe is deliberately ALLOWED (legit in name-shaped keys like "o'brien");
43
+ # double-quote is rejected because it is also the FTS5 phrase delimiter.
44
+ #
45
+ # NOTE: we reject the traversal MARKER ".." (catches KAPPA's "../../etc/passwd"
46
+ # and "..\\..\\windows") but NOT bare "/" or "\\" -- the v0.4.0 contract
47
+ # explicitly permits slash-containing keys ("with/slash"). Rejecting raw path
48
+ # separators for export-safety would be a public-contract change; flagged for
49
+ # the team rather than taken unilaterally.
50
+ _IDENT_FORBIDDEN_SUBSTRINGS = ("..",)
51
+ _IDENT_FORBIDDEN_CHARS = frozenset('<>|;"`')
52
+
53
 
54
  def validate_identifier(value: Any, *, field_name: str) -> str:
55
  """Validate a user-supplied identifier (entity name, state key, etc.).
 
88
  f"Remove control characters / null bytes / tabs / newlines."
89
  ),
90
  )
91
+ # v0.4.4: path-traversal + dangerous metacharacter defense-in-depth.
92
+ for bad in _IDENT_FORBIDDEN_SUBSTRINGS:
93
+ if bad in value:
94
+ raise ValidationError(
95
+ f"{field_name} contains a forbidden path sequence ({bad!r})",
96
+ recovery=(
97
+ "Identifiers are flat keys, not paths. Remove '/', '\\', "
98
+ "and '..' sequences."
99
+ ),
100
+ )
101
+ bad_chars = sorted(_IDENT_FORBIDDEN_CHARS & set(value))
102
+ if bad_chars:
103
+ raise ValidationError(
104
+ f"{field_name} contains forbidden character(s): {' '.join(bad_chars)}",
105
+ recovery=(
106
+ "Remove shell / redirection / quote metacharacters "
107
+ "( < > | ; \" ` ) from the identifier. Apostrophe is allowed."
108
+ ),
109
+ )
110
  return value
111
 
112
 
 
181
  "payload", "ts", "rowid"})
182
 
183
 
184
+ # v0.4.4 (chainriffs Discord report + KAPPA #4): bare uppercase FTS5 operator
185
+ # keywords typed inside a natural-language query ("auth AND db", "cache NEAR
186
+ # eviction") were being phrase-quoted into REQUIRED LITERAL tokens, so a matched
187
+ # row had to literally contain the word "AND" / "NEAR" -- recall silently
188
+ # collapsed to ~0 hits. Users mean these as connectors, not search terms. Drop
189
+ # them during tokenization so the remaining terms AND together (FTS5's implicit
190
+ # space-join), which is the natural intent. If a query is ONLY operator keywords,
191
+ # keep them as literals so a genuine search for the word "and" still resolves.
192
+ _FTS5_OPERATOR_KEYWORDS = frozenset({"AND", "OR", "NOT", "NEAR"})
193
+
194
+
195
+ def _drop_fts5_operator_tokens(tokens: list[str]) -> list[str]:
196
+ """Drop standalone FTS5 operator keywords; keep all tokens if that empties it."""
197
+ kept = [t for t in tokens if t.upper() not in _FTS5_OPERATOR_KEYWORDS]
198
+ return kept or tokens
199
+
200
+
201
  def _sanitize_fts5_query(raw: str, *, prefix: bool = False, as_phrase: bool = False) -> str:
202
  """Wrap a user query as a safe FTS5 MATCH expression.
203
 
 
248
  tokens = [t for t in cleaned.split() if t]
249
  if not tokens:
250
  return ""
251
+ tokens = _drop_fts5_operator_tokens(tokens)
252
  if len(tokens) == 1:
253
  return f"{tokens[0]}*"
254
  # Multiple tokens: all earlier tokens are literal, the last gets `*`.
 
270
  # query still has SOME defensible shape rather than empty.
271
  escaped = s.replace('"', '""')
272
  return f'"{escaped}"'
273
+ tokens = _drop_fts5_operator_tokens(tokens)
274
  return " ".join(f'"{t}"' for t in tokens)
275
 
276
  # The default tenant for single-user local installs.
sibyl-memory-client/tests/test_kappa_fixes.py CHANGED
@@ -281,3 +281,70 @@ def test_search_entities_phrase_match_semantics(tmp_path):
281
  # "*" is wrapped as a literal phrase
282
  hits = client.search_entities("*")
283
  assert hits == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  # "*" is wrapped as a literal phrase
282
  hits = client.search_entities("*")
283
  assert hits == []
284
+
285
+
286
+ # ----------------------------------------------------------------------
287
+ # v0.4.4: entity-name path-traversal + metacharacter defense-in-depth
288
+ # (KAPPA #3 PARTIAL — path-traversal shape + SQL-keyword shape were ACCEPTED)
289
+ # ----------------------------------------------------------------------
290
+
291
+ def test_validate_identifier_rejects_path_traversal():
292
+ from sibyl_memory_client.client import validate_identifier
293
+ from sibyl_memory_client.exceptions import ValidationError
294
+ # ".." traversal marker is rejected; bare "/" stays allowed per the v0.4.0
295
+ # contract (test_validate_identifier_accepts_reasonable covers "with/slash").
296
+ for bad in ("../../etc/passwd", "..\\..\\windows", "foo/..", ".."):
297
+ with pytest.raises(ValidationError, match="forbidden path sequence"):
298
+ validate_identifier(bad, field_name="name")
299
+
300
+
301
+ def test_validate_identifier_rejects_sql_and_shell_metacharacters():
302
+ from sibyl_memory_client.client import validate_identifier
303
+ from sibyl_memory_client.exceptions import ValidationError
304
+ # KAPPA's SQL-keyword shape ("'; DROP TABLE entities;--") is caught by ';'
305
+ for bad in ("'; DROP TABLE entities;--", "a;b", 'a"b', "a`b", "a|b", "a<b", "a>b"):
306
+ with pytest.raises(ValidationError, match="forbidden character"):
307
+ validate_identifier(bad, field_name="name")
308
+
309
+
310
+ def test_validate_identifier_allows_apostrophe_and_normal_names():
311
+ """Apostrophe is deliberately allowed so name-shaped keys survive; plain
312
+ identifiers, dashes, underscores, dots-without-traversal pass."""
313
+ from sibyl_memory_client.client import validate_identifier
314
+ for ok in ("o'brien", "acme-deal", "alice", "project_atlas", "v0.4.4", "L-S-ratio"):
315
+ assert validate_identifier(ok, field_name="name") == ok
316
+
317
+
318
+ # ----------------------------------------------------------------------
319
+ # v0.4.4: FTS5 operator-keyword drop
320
+ # (chainriffs Discord + KAPPA #4 — uppercase AND/OR/NOT/NEAR became required
321
+ # literal tokens, silently collapsing recall to ~0 hits)
322
+ # ----------------------------------------------------------------------
323
+
324
+ def test_sanitizer_drops_operator_keywords_default_mode():
325
+ from sibyl_memory_client.client import _sanitize_fts5_query
326
+ # operator words must NOT survive as quoted literal tokens
327
+ assert _sanitize_fts5_query("auth AND db") == '"auth" "db"'
328
+ assert _sanitize_fts5_query("cache NEAR eviction") == '"cache" "eviction"'
329
+ assert _sanitize_fts5_query("foo OR bar NOT baz") == '"foo" "bar" "baz"'
330
+
331
+
332
+ def test_sanitizer_keeps_operator_only_query_as_literal():
333
+ """If the query is ONLY operator keywords, keep them so a genuine search
334
+ for the literal word 'and' still resolves (no empty-query surprise)."""
335
+ from sibyl_memory_client.client import _sanitize_fts5_query
336
+ assert _sanitize_fts5_query("AND") == '"AND"'
337
+ assert _sanitize_fts5_query("AND OR NOT") == '"AND" "OR" "NOT"'
338
+
339
+
340
+ def test_search_with_operator_words_returns_hits_end_to_end(tmp_path):
341
+ """The actual reported failure: a natural-language query containing an
342
+ uppercase operator word used to return 0 hits. It must now match."""
343
+ from sibyl_memory_client import MemoryClient
344
+ client = MemoryClient.local(tmp_path / "memory.db")
345
+ client.set_entity("debug", "authnote",
346
+ {"text": "auth uses JWT and a db connection for cache eviction"})
347
+ # Pre-fix: "AND"/"NEAR" became required literal tokens -> 0 hits.
348
+ assert len(client.search("auth AND db")) >= 1
349
+ assert len(client.search("cache NEAR eviction")) >= 1
350
+ assert len(client.search_entities("auth AND db")) >= 1
sibyl-memory-mcp/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to `sibyl-memory-mcp` are recorded here. Format follows
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
5
  [SemVer](https://semver.org/).
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## [0.1.2] - 2026-05-18
8
 
9
  KAPPA external-tester remediation release. v0.1.1 was functionally broken
 
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
5
  [SemVer](https://semver.org/).
6
 
7
+ ## [0.1.3] - 2026-05-28
8
+
9
+ Beta-tester bug-report remediation (sylvain1550 Discord + QA note).
10
+
11
+ ### Fixed
12
+
13
+ - **First-use writes failed with an opaque `SQLite IntegrityError`
14
+ pre-activation.** With no `credentials.json`, `_build_client()` passed
15
+ `tenant_id=None` *explicitly*, overriding the SDK's `DEFAULT_TENANT`
16
+ default. Every write then violated the `entities.tenant_id NOT NULL`
17
+ constraint while reads + tool discovery still worked — so a broken
18
+ install looked healthy. Now falls back to `DEFAULT_TENANT`, matching
19
+ `sibyl-memory-hermes`' provider behavior. Free local pre-activation
20
+ writes succeed. (Regression test: `tests/test_first_use_tenant.py`.)
21
+ - **`__version__` drift.** The hardcoded `"0.1.0"` had drifted from the
22
+ `0.1.2` published wheel. Now single-sourced from installed metadata via
23
+ `importlib.metadata` (mirrors `sibyl-memory-client`), so it can never
24
+ drift again.
25
+
26
+ ### Changed
27
+
28
+ - Pin bumped to `sibyl-memory-client>=0.4.4` (FTS5 + identifier fixes).
29
+
30
  ## [0.1.2] - 2026-05-18
31
 
32
  KAPPA external-tester remediation release. v0.1.1 was functionally broken
sibyl-memory-mcp/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "sibyl-memory-mcp"
7
- version = "0.1.2"
8
  description = "MCP server for Sibyl Memory Plugin: wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP)."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
@@ -23,7 +23,7 @@ classifiers = [
23
  ]
24
  dependencies = [
25
  "mcp>=1.0.0",
26
- "sibyl-memory-client>=0.4.0",
27
  "sibyl-memory-hermes>=0.3.2",
28
  ]
29
 
 
4
 
5
  [project]
6
  name = "sibyl-memory-mcp"
7
+ version = "0.1.3"
8
  description = "MCP server for Sibyl Memory Plugin: wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP)."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
23
  ]
24
  dependencies = [
25
  "mcp>=1.0.0",
26
+ "sibyl-memory-client>=0.4.4",
27
  "sibyl-memory-hermes>=0.3.2",
28
  ]
29
 
sibyl-memory-mcp/src/sibyl_memory_mcp/__init__.py CHANGED
@@ -24,5 +24,14 @@ memory.db exist at ~/.sibyl-memory/.
24
 
25
  from .server import build_server, run_stdio
26
 
27
- __version__ = "0.1.0"
 
 
 
 
 
 
 
 
 
28
  __all__ = ["build_server", "run_stdio", "__version__"]
 
24
 
25
  from .server import build_server, run_stdio
26
 
27
+ # Single-sourced from installed metadata so the wheel + code never drift
28
+ # (v0.1.3: the hardcoded "0.1.0" had drifted from the 0.1.2 published wheel;
29
+ # mirrors sibyl-memory-client's dynamic-version pattern). Source-tree fallback
30
+ # for editable installs that haven't been pip-installed yet.
31
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
32
+ try:
33
+ __version__ = _pkg_version("sibyl-memory-mcp")
34
+ except PackageNotFoundError: # pragma: no cover - source-tree dev only
35
+ __version__ = "0.0.0+source"
36
+
37
  __all__ = ["build_server", "run_stdio", "__version__"]
sibyl-memory-mcp/src/sibyl_memory_mcp/server.py CHANGED
@@ -37,7 +37,7 @@ from pathlib import Path
37
  from typing import Any
38
 
39
  from mcp.server.fastmcp import FastMCP
40
- from sibyl_memory_client import MemoryClient
41
  from sibyl_memory_client.exceptions import (
42
  CapExceededError,
43
  NotFoundError,
@@ -129,12 +129,22 @@ def _open_client() -> MemoryClient:
129
 
130
 
131
  def _build_client() -> MemoryClient:
132
- """Construct a fresh MemoryClient. Called only on cache miss."""
 
 
 
 
 
 
 
 
 
 
133
  creds = _load_credentials()
134
  DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
135
  return MemoryClient.local(
136
  str(DEFAULT_DB_PATH),
137
- tenant_id=creds.get("tenant_id"),
138
  account_id=creds.get("account_id"),
139
  session_token=creds.get("session_token"),
140
  tier=creds.get("tier", "free"),
 
37
  from typing import Any
38
 
39
  from mcp.server.fastmcp import FastMCP
40
+ from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
41
  from sibyl_memory_client.exceptions import (
42
  CapExceededError,
43
  NotFoundError,
 
129
 
130
 
131
  def _build_client() -> MemoryClient:
132
+ """Construct a fresh MemoryClient. Called only on cache miss.
133
+
134
+ v0.1.3 (sylvain1550 / KAPPA first-use bug): when credentials.json is
135
+ absent, ``creds`` is ``{}`` and ``creds.get("tenant_id")`` is ``None``.
136
+ Passing ``tenant_id=None`` *explicitly* overrode the SDK's DEFAULT_TENANT
137
+ default, so every write hit the ``entities.tenant_id NOT NULL`` constraint
138
+ and failed with an opaque ``SQLite error: IntegrityError`` -- while reads
139
+ and tool discovery still worked, making a broken install look healthy.
140
+ Fall back to DEFAULT_TENANT so pre-activation free local mode writes
141
+ succeed, matching sibyl-memory-hermes' provider behavior.
142
+ """
143
  creds = _load_credentials()
144
  DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
145
  return MemoryClient.local(
146
  str(DEFAULT_DB_PATH),
147
+ tenant_id=creds.get("tenant_id") or DEFAULT_TENANT,
148
  account_id=creds.get("account_id"),
149
  session_token=creds.get("session_token"),
150
  tier=creds.get("tier", "free"),
sibyl-memory-mcp/tests/test_first_use_tenant.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression test for the v0.1.3 first-use write bug.
2
+
3
+ Bug (sylvain1550 Discord report 2026-05-27 + QA note run-2026-05-28-mcp-run05;
4
+ related to KAPPA's coordination thread): with no credentials.json present,
5
+ `_build_client()` passed `tenant_id=creds.get("tenant_id")` == None EXPLICITLY,
6
+ overriding MemoryClient.local's DEFAULT_TENANT default. Every write then hit the
7
+ `entities.tenant_id NOT NULL` constraint and failed with an opaque
8
+ `SQLite error: IntegrityError`, while reads + tool discovery still worked -- so a
9
+ broken install looked healthy.
10
+
11
+ Fix: `tenant_id=creds.get("tenant_id") or DEFAULT_TENANT`. This test fails on the
12
+ pre-fix code (IntegrityError) and passes after.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # Make both packages importable from a source checkout.
20
+ _HERE = Path(__file__).resolve()
21
+ sys.path.insert(0, str(_HERE.parent.parent / "src"))
22
+ sys.path.insert(0, str(_HERE.parent.parent.parent / "sibyl-memory-client" / "src"))
23
+
24
+
25
+ def test_build_client_writes_succeed_without_credentials(tmp_path, monkeypatch):
26
+ import sibyl_memory_mcp.server as server
27
+ from sibyl_memory_client import DEFAULT_TENANT
28
+
29
+ monkeypatch.setattr(server, "DEFAULT_DB_PATH", tmp_path / "memory.db")
30
+ monkeypatch.setattr(server, "DEFAULT_CRED_PATH", tmp_path / "credentials.json")
31
+ # credentials.json deliberately absent -> pre-activation free local mode.
32
+ assert not (tmp_path / "credentials.json").exists()
33
+
34
+ client = server._build_client()
35
+ assert client is not None
36
+
37
+ # THE regression: this write raised StorageError(IntegrityError) before the fix.
38
+ client.set_entity("debug", "first-use", {"text": "pre-activation write probe"})
39
+
40
+ # And it must be retrievable, proving the row actually landed under a tenant.
41
+ hits = client.search_entities("probe")
42
+ assert len(hits) >= 1
43
+ assert hits[0]["tenant_id"] == DEFAULT_TENANT
44
+
45
+
46
+ def test_build_client_honors_real_tenant_when_present(tmp_path, monkeypatch):
47
+ """When credentials DO carry a tenant_id, it is still used (no regression)."""
48
+ import json
49
+ import sibyl_memory_mcp.server as server
50
+
51
+ cred = tmp_path / "credentials.json"
52
+ real_tenant = "11111111-1111-1111-1111-111111111111"
53
+ cred.write_text(json.dumps({"tenant_id": real_tenant, "account_id": "acct", "tier": "free"}))
54
+ monkeypatch.setattr(server, "DEFAULT_DB_PATH", tmp_path / "memory.db")
55
+ monkeypatch.setattr(server, "DEFAULT_CRED_PATH", cred)
56
+
57
+ client = server._build_client()
58
+ client.set_entity("debug", "scoped", {"text": "scoped write probe"})
59
+ hits = client.search_entities("scoped")
60
+ assert len(hits) >= 1
61
+ assert hits[0]["tenant_id"] == real_tenant