sibyllabs commited on
Commit
6700c84
·
1 Parent(s): 8b1226b

fix(ux): MCP tool errors set isError; add `sibyl migrate --force`

Browse files

bugflow 2026-06-05 (UX focus). Two reporter-sourced gaps, both verified.

sibyl-memory-mcp 0.1.6 -> 0.1.7
_err() returned a plain dict, so FastMCP delivered tool errors as SUCCESSFUL
results (isError:false) with the error nested inside the payload. Agents
keying off the protocol-level isError flag could not detect failures. _err()
now raises ToolError carrying the same structured payload as JSON
(isError:true; error/code/recovery/upgrade_url preserved). Tool signatures
unchanged; only the error envelope is corrected.

sibyl-memory-cli 0.3.11 -> 0.3.12
`sibyl migrate` had no --force flag, so a detected harness already holding a
non-sibyl memory provider dead-ended at "Use --force to overwrite." with no
flag to pass. Added --force, threaded cli -> run_guided_setup(force=) ->
wire(force=force).

Tests: 34 green (16 mcp + 4 new mcp regression + migrate suite + 2 new cli
force-threading). 10 pre-existing CodexWirer/ClaudeWirer config-format failures
in setup.py are unrelated (that file is untouched by this change).

sibyl-memory-cli/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `sibyl-memory-cli` 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.3.11] — 2026-06-01
8
 
9
  ### Fixed
 
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
5
  [SemVer](https://semver.org/).
6
 
7
+ ## [0.3.12] — 2026-06-05
8
+
9
+ ### Fixed
10
+
11
+ - **`sibyl migrate --force` (onboarding dead-end).** When a detected harness
12
+ already had a non-sibyl memory provider, the wirer refused with
13
+ "Use --force to overwrite." but `run_guided_setup` called `wire()` with no
14
+ `force`, and `sibyl migrate` had no `--force` flag to pass, leaving the user
15
+ with no way forward. Added `--force` to `sibyl migrate`, threaded through
16
+ `cmd_migrate` → `run_guided_setup(force=)` → `wire(force=force)`, so migration
17
+ can overwrite an existing provider when the user explicitly opts in.
18
+ Regression coverage: `tests/test_migrate_force_2026_06_05.py`. (bugflow)
19
+
20
  ## [0.3.11] — 2026-06-01
21
 
22
  ### Fixed
sibyl-memory-cli/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "sibyl-memory-cli"
7
- version = "0.3.11"
8
  description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
  license = { text = "MIT" }
 
4
 
5
  [project]
6
  name = "sibyl-memory-cli"
7
+ version = "0.3.12"
8
  description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
  license = { text = "MIT" }
sibyl-memory-cli/src/sibyl_memory_cli/cli.py CHANGED
@@ -1056,7 +1056,7 @@ def cmd_migrate(args: argparse.Namespace) -> int:
1056
  io = _migrate_io()
1057
  report = M.run_guided_setup(
1058
  home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
1059
- io=io, debloat=not args.no_debloat,
1060
  )
1061
 
1062
  ph = report.get("phases", {})
@@ -1196,6 +1196,11 @@ def build_parser() -> argparse.ArgumentParser:
1196
  "--yes", "-y", action="store_true",
1197
  help="Skip the initial confirm (the trim step still always asks separately)",
1198
  )
 
 
 
 
 
1199
  p_migrate.set_defaults(func=cmd_migrate)
1200
 
1201
  return p
 
1056
  io = _migrate_io()
1057
  report = M.run_guided_setup(
1058
  home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
1059
+ io=io, debloat=not args.no_debloat, force=getattr(args, "force", False),
1060
  )
1061
 
1062
  ph = report.get("phases", {})
 
1196
  "--yes", "-y", action="store_true",
1197
  help="Skip the initial confirm (the trim step still always asks separately)",
1198
  )
1199
+ p_migrate.add_argument(
1200
+ "--force", action="store_true",
1201
+ help="Overwrite an existing non-sibyl memory provider when wiring a harness "
1202
+ "(without this, migrate stops at that harness and tells you to re-run with --force)",
1203
+ )
1204
  p_migrate.set_defaults(func=cmd_migrate)
1205
 
1206
  return p
sibyl-memory-cli/src/sibyl_memory_cli/migrate.py CHANGED
@@ -330,7 +330,7 @@ class GuidedIO:
330
  def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
331
  io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
332
  extract_fn: Optional[Callable[[Path, Path], None]] = None,
333
- debloat: bool = True, now=None) -> dict:
334
  """The assembled guided flow: backup -> auto-wire each harness (instructions on
335
  failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
336
  report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
@@ -367,7 +367,7 @@ def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
367
  if w.current_state().get("wired_with_sibyl"):
368
  wire_report[name] = "already"
369
  continue
370
- outcome = w.wire()
371
  wire_report[name] = outcome.status
372
  if outcome.status not in ("wired", "already"):
373
  io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
 
330
  def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
331
  io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
332
  extract_fn: Optional[Callable[[Path, Path], None]] = None,
333
+ debloat: bool = True, force: bool = False, now=None) -> dict:
334
  """The assembled guided flow: backup -> auto-wire each harness (instructions on
335
  failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
336
  report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
 
367
  if w.current_state().get("wired_with_sibyl"):
368
  wire_report[name] = "already"
369
  continue
370
+ outcome = w.wire(force=force)
371
  wire_report[name] = outcome.status
372
  if outcome.status not in ("wired", "already"):
373
  io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
sibyl-memory-cli/tests/test_migrate_force_2026_06_05.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression (bugflow 2026-06-05): `sibyl migrate --force` must reach the wirers.
2
+
3
+ Onboarding dead-end: when a detected harness already had a non-sibyl memory
4
+ provider, the wirer refused with "Use --force to overwrite." but
5
+ `run_guided_setup` called `wire()` with no `force`, and `sibyl migrate` had no
6
+ `--force` flag to pass. The flag now threads cli -> run_guided_setup(force=) ->
7
+ wire(force=force).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from sibyl_memory_cli import migrate as M
12
+ from sibyl_memory_cli.setup import WireOutcome
13
+ from sibyl_memory_client import MemoryClient
14
+
15
+
16
+ class _RecordingWirer:
17
+ """A fake harness wirer that records the `force` kwarg it was called with."""
18
+
19
+ name = "rec"
20
+
21
+ def __init__(self):
22
+ self.seen_force = None
23
+
24
+ def is_present(self):
25
+ return True
26
+
27
+ def current_state(self):
28
+ return {"wired_with_sibyl": False}
29
+
30
+ def wire(self, *, force: bool = False, dry_run: bool = False, prompt_fn=None):
31
+ self.seen_force = force
32
+ return WireOutcome(self.name, "wired", "ok")
33
+
34
+
35
+ def _home_with_memory(tmp_path):
36
+ h = tmp_path / "home"
37
+ (h / "proj").mkdir(parents=True)
38
+ (h / "proj" / "CLAUDE.md").write_text("# memory\n- a fact worth keeping\n")
39
+ db = h / ".sibyl-memory" / "memory.db"
40
+ db.parent.mkdir(parents=True)
41
+ return h, db
42
+
43
+
44
+ def _fake_extract(_backup_dir, db_path):
45
+ MemoryClient.local(str(db_path), tenant_id="qa").set_entity("f", "a", {"v": 1})
46
+
47
+
48
+ def test_force_true_threads_to_wirer(tmp_path):
49
+ h, db = _home_with_memory(tmp_path)
50
+ rec = _RecordingWirer()
51
+ M.run_guided_setup(
52
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
53
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
54
+ extract_fn=_fake_extract, force=True,
55
+ )
56
+ assert rec.seen_force is True
57
+
58
+
59
+ def test_force_defaults_false(tmp_path):
60
+ h, db = _home_with_memory(tmp_path)
61
+ rec = _RecordingWirer()
62
+ M.run_guided_setup(
63
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
64
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
65
+ extract_fn=_fake_extract,
66
+ )
67
+ assert rec.seen_force is False
sibyl-memory-mcp/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ 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.6] - 2026-06-04
8
 
9
  ### Added
 
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
5
  [SemVer](https://semver.org/).
6
 
7
+ ## [0.1.7] - 2026-06-05
8
+
9
+ ### Fixed
10
+
11
+ - **Tool errors now set the MCP `isError` flag (agent error-detection).**
12
+ `_err()` previously returned a plain dict, which FastMCP delivered as a
13
+ *successful* tool result (`isError: false`) with the error nested inside the
14
+ payload, so an agent keying off the protocol-level `isError` flag could not
15
+ detect the failure at all. `_err()` now raises `ToolError` carrying the same
16
+ structured payload encoded as JSON, so callers both (a) see `isError: true`
17
+ and (b) can still parse `error`/`code`/`recovery`/`upgrade_url` from the
18
+ message. No tool signatures change; only the error envelope is corrected.
19
+ Regression coverage: `tests/test_err_toolerror_2026_06_05.py`. (bugflow)
20
+
21
  ## [0.1.6] - 2026-06-04
22
 
23
  ### Added
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.6"
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"
 
4
 
5
  [project]
6
  name = "sibyl-memory-mcp"
7
+ version = "0.1.7"
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"
sibyl-memory-mcp/src/sibyl_memory_mcp/server.py CHANGED
@@ -35,9 +35,10 @@ import os
35
  import re
36
  import threading
37
  from pathlib import Path
38
- from typing import Any
39
 
40
  from mcp.server.fastmcp import FastMCP
 
41
  from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
42
  from sibyl_memory_client.exceptions import (
43
  CapExceededError,
@@ -166,8 +167,16 @@ def _build_client() -> MemoryClient:
166
  # Error mapping
167
  # ----------------------------------------------------------------------
168
 
169
- def _err(e: Exception) -> dict[str, Any]:
170
- """Map SDK exception structured error payload the agent can reason about."""
 
 
 
 
 
 
 
 
171
  cls = type(e).__name__
172
  payload = {"error": cls, "message": str(e)}
173
  if isinstance(e, CapExceededError):
@@ -184,7 +193,7 @@ def _err(e: Exception) -> dict[str, Any]:
184
  payload["code"] = "NOT_FOUND"
185
  elif isinstance(e, ValidationError):
186
  payload["code"] = "VALIDATION_ERROR"
187
- return payload
188
 
189
 
190
  def _coerce_body(body: Any) -> Any:
 
35
  import re
36
  import threading
37
  from pathlib import Path
38
+ from typing import Any, NoReturn
39
 
40
  from mcp.server.fastmcp import FastMCP
41
+ from mcp.server.fastmcp.exceptions import ToolError
42
  from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
43
  from sibyl_memory_client.exceptions import (
44
  CapExceededError,
 
167
  # Error mapping
168
  # ----------------------------------------------------------------------
169
 
170
+ def _err(e: Exception) -> NoReturn:
171
+ """Map an SDK exception to a ToolError so the MCP envelope sets isError=true.
172
+
173
+ Previously this returned a plain dict, which FastMCP delivered as a
174
+ SUCCESSFUL tool result (isError=false) with the error nested inside the
175
+ payload. An agent keying off the protocol-level isError flag could not
176
+ detect the failure at all. We now raise ToolError carrying the same
177
+ structured payload encoded as JSON, so callers both (a) see isError=true
178
+ and (b) can still parse error/code/recovery/upgrade_url from the message.
179
+ """
180
  cls = type(e).__name__
181
  payload = {"error": cls, "message": str(e)}
182
  if isinstance(e, CapExceededError):
 
193
  payload["code"] = "NOT_FOUND"
194
  elif isinstance(e, ValidationError):
195
  payload["code"] = "VALIDATION_ERROR"
196
+ raise ToolError(json.dumps(payload, ensure_ascii=False))
197
 
198
 
199
  def _coerce_body(body: Any) -> Any:
sibyl-memory-mcp/tests/test_err_toolerror_2026_06_05.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression (bugflow 2026-06-05): tool errors must set the MCP `isError` flag.
2
+
3
+ `_err()` used to return a plain dict, which FastMCP delivered as a *successful*
4
+ tool result (`isError: false`) with the error nested inside the payload — an
5
+ agent keying off the protocol-level `isError` flag could not detect the failure
6
+ at all. `_err()` now raises `ToolError` carrying the same structured payload as
7
+ JSON, so callers see `isError: true` AND can still parse error/code/recovery.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ import pytest
14
+ from mcp.server.fastmcp.exceptions import ToolError
15
+
16
+ import sibyl_memory_mcp.server as server
17
+ from sibyl_memory_client.exceptions import (
18
+ CapExceededError,
19
+ NotFoundError,
20
+ TierGateError,
21
+ ValidationError,
22
+ )
23
+
24
+
25
+ def test_err_raises_toolerror_not_returns_dict():
26
+ # The whole point of the fix: _err raises rather than returns.
27
+ with pytest.raises(ToolError):
28
+ server._err(NotFoundError("entity not found"))
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ "exc, code",
33
+ [
34
+ (CapExceededError("over the 2 MB free-tier cap", current_size=3_000_000, cap=2_000_000), "CAP_EXCEEDED"),
35
+ (TierGateError("paid feature", feature="self_learning"), "TIER_GATED"),
36
+ (NotFoundError("missing"), "NOT_FOUND"),
37
+ (ValidationError("bad body"), "VALIDATION_ERROR"),
38
+ ],
39
+ )
40
+ def test_err_toolerror_preserves_structured_payload(exc, code):
41
+ with pytest.raises(ToolError) as ei:
42
+ server._err(exc)
43
+ payload = json.loads(str(ei.value))
44
+ assert payload["code"] == code
45
+ assert payload["error"] == type(exc).__name__
46
+ assert payload["message"] # non-empty human message survives
47
+
48
+
49
+ def test_cap_exceeded_keeps_recovery_and_upgrade_url():
50
+ with pytest.raises(ToolError) as ei:
51
+ server._err(CapExceededError("cap", current_size=3_000_000, cap=2_000_000))
52
+ payload = json.loads(str(ei.value))
53
+ assert "Run `sibyl upgrade`" in payload["recovery"]
54
+ assert payload["upgrade_url"].startswith("https://")