Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
},
"metadata": {
"description": "Persistent memory and cognitive profiling plugins for Claude Code",
"version": "3.24.0"
"version": "3.24.1"
},
"plugins": [
{
"name": "cortex",
"source": "./",
"description": "Persistent memory and cognitive profiling for Claude Code — thermodynamic memory with heat/decay, intent-aware retrieval, biological plasticity, codebase intelligence, and cognitive profiling. 43 MCP tools (46 with the optional automatised-pipeline + prd-spec-generator integrations) with enriched schemas (visualization extracted to the standalone cortex-viz MCP). PostgreSQL + pgvector in CLI mode; automatic SQLite fallback in Cowork/sandboxed mode. v3.17.0 — autonomous per-project wiki: SessionStart auto-spawns a 6-hour consolidate cycle; a headless `claude -p` worker drains the curation-gap queue, calls codebase-intelligence MCP tools to ground each section in the real call graph, and authors missing anchor pages (architecture / services / api / data-flow / operations / decisions / PRD) per project from the source tree. 15 canonical scopes × 13 file sections; per-project dashboards under `wiki/_dashboards/`. Mermaid diagrams have a 🔍 lens with zoom + pan. Workflow graph with caller-qualified CALLS chains rendering full method-to-method dependencies (native tree-sitter, no AP required). Side panel humanized for non-technical users. Ingests codebase analysis (ai-automatised-pipeline) and PRDs (prd-spec-generator) into wiki + memory + knowledge graph. Docker image available.",
"version": "3.24.0",
"version": "3.24.1",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cortex",
"description": "Persistent memory for Claude Code — remembers across sessions automatically. Install and forget. Scientific retrieval backed by 72 published references.",
"version": "3.24.0",
"version": "3.24.1",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ htmlcov/
dist/
build/
.pytest_cache/
# mutmut working copy + cache (mutation testing artefacts, regenerated per run)
mutants/
.mutmut-cache
# Built MCPB bundle — release artifact attached to GitHub Releases, never committed
*.mcpb
.python-version
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [3.24.1] - 2026-06-23

Cross-backend `recall` fix — PostgreSQL users could not use `recall`.

### Fixed
- **`recall` (and every read tool) on PostgreSQL.** The PostgreSQL store returns
`numpy.float32` scores and `datetime` timestamps where the SQLite store returns
`float`/`str`. FastMCP can only build `structuredContent` from JSON-native
values, so a non-native field silently dropped `structuredContent` and the
Claude Code host rejected the call with *"outputSchema defined but no structured
output returned"* — on PostgreSQL only, while SQLite-backed tests stayed green.
Added `mcp_server/shared/json_native.py::to_json_native`, applied at the
`tool_error_handler.safe_handler` boundary every tool crosses, normalizing
results to one JSON-native shape regardless of backend.

### Added
- Mutation testing (mutmut): `[tool.mutmut]` config + `scripts/mutation_check.sh`
scoped per-change runner. Mandated on changed code by coding-standards §12.

## [3.23.0] - 2026-06-17

Registry-indexer build fix. No runtime behaviour change.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,5 @@
"type": "string"
}
},
"version": "3.24.0"
"version": "3.24.1"
}
80 changes: 80 additions & 0 deletions mcp_server/shared/json_native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Coerce handler results to JSON-native types for the MCP wire.

Why this exists
---------------
FastMCP 2.x builds a tool's ``structuredContent`` by JSON-serializing the
handler's return value and validating it against the declared
``output_schema``. If the value carries a non-JSON-native type the
serializer fails and FastMCP emits **no** ``structuredContent`` — the
Claude Code client then rejects the call with "outputSchema defined but
no structured output returned" (reproduced 2026-06-23: ``recall`` returned
memory rows whose ``score`` was ``numpy.float32`` and ``created_at`` a
``datetime``; ``remember`` worked only because its response was already
native).

The wire contract is JSON. The store hands back DB-native types
(``datetime`` from psycopg, ``numpy`` scalars from vector math). This
module enforces the contract at one place — the handler→FastMCP boundary
(``tool_error_handler.safe_handler``) — so every tool is covered, not
just the ones touched today (OCP: new tools inherit the guarantee).

``datetime``/``date``/``time`` become ISO-8601 strings, which also
satisfy ``{"type": "string", "format": "date-time"}`` output schemas.

shared/ layer: Python stdlib only. numpy is handled by duck typing
(``numbers`` registration + ``.item()``/``.tolist()``), never imported.
"""

from __future__ import annotations

import datetime as _dt
import numbers
from typing import Any


def to_json_native(obj: Any) -> Any:
"""Recursively convert ``obj`` to JSON-serializable, schema-friendly types.

Native passthroughs (``None``/``str``/``bool``) are returned unchanged,
so handlers already returning clean dicts pay only a shallow walk.
"""
# str and bool first: bool is a subclass of numbers.Integral, and str
# is iterable — both must not fall through to the numeric/sequence arms.
if obj is None or isinstance(obj, (str, bool)):
return obj
if isinstance(obj, dict):
return {str(k): to_json_native(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set, frozenset)):
return [to_json_native(v) for v in obj]
# Temporal → ISO-8601 string (also matches `format: date-time` schemas).
if isinstance(obj, (_dt.datetime, _dt.date, _dt.time)):
return obj.isoformat()
if isinstance(obj, (bytes, bytearray)):
return obj.decode("utf-8", "replace")
# numbers.Integral/Real cover numpy.int*/float* (both registered).
if isinstance(obj, numbers.Integral):
return int(obj)
if isinstance(obj, numbers.Real):
return float(obj)
if isinstance(obj, numbers.Number):
# Decimal is a numbers.Number but NOT numbers.Real (PG NUMERIC maps
# to Decimal via psycopg). complex has no float() → falls through to
# the str fallback below, which stays JSON-safe.
try:
return float(obj)
except (TypeError, ValueError):
pass
# numpy arrays AND 0-d scalars (incl. numpy.bool_) expose tolist(),
# which returns native python values. Every numpy scalar has both
# tolist() and item(), so a separate item() branch would be
# unreachable dead code — mutation testing (2026-06-23) confirmed all
# its mutants survived because no input ever reaches it past tolist().
tolist = getattr(obj, "tolist", None)
if callable(tolist):
try:
return to_json_native(tolist())
except Exception:
pass
# Last resort: stringify so the wire payload stays JSON-safe rather
# than dropping structuredContent entirely.
return str(obj)
13 changes: 12 additions & 1 deletion mcp_server/tool_error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ async def tool_remember(...) -> dict:
import asyncio
from typing import Any, Callable, Coroutine

from mcp_server.shared.json_native import to_json_native

_DB_SETUP_GUIDE = (
"Cortex could not connect to PostgreSQL. "
"This usually means the database is not set up yet.\n\n"
Expand Down Expand Up @@ -166,7 +168,16 @@ async def safe_handler(
# not reject the response.
if result is None:
return {}
return result
# Single wire format across backends. The PG store returns
# ``datetime``/``numpy`` scalars where the SQLite store returns
# ``str``/``float``; FastMCP can only build ``structuredContent``
# from JSON-native values, so a non-native field silently drops
# structuredContent and the client rejects the call ("outputSchema
# defined but no structured output returned"). Normalizing here —
# the one boundary every handler crosses — guarantees an identical,
# schema-friendly return shape regardless of which backend produced
# it. Native dicts (e.g. remember) pass through unchanged.
return to_json_native(result)
except Exception as exc:
error_type, message = _classify_error(exc)
if tool_name:
Expand Down
28 changes: 27 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hypermnesia-mcp"
version = "3.24.0"
version = "3.24.1"
description = "Scientifically-grounded memory system based on computational neuroscience research"
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -141,3 +141,29 @@ source = ["mcp_server"]

[tool.coverage.report]
show_missing = true

[dependency-groups]
dev = [
"mutmut>=3.6.0",
]

[tool.mutmut]
# Mutation testing (mutmut 3.x). Mutation score — not line coverage — is
# the real signal that a test SUITE can detect a regression: a mutant that
# survives is a behavior no test pins. This guards against the class of bug
# where green tests on one backend (SQLite) hide a failure on another (PG),
# e.g. the 2026-06-23 recall structuredContent regression.
#
# This committed scope is narrowed to the demonstrated module; per-change
# runs widen `source_paths`/`only_mutate` to the touched files (see
# scripts/mutation_check.sh). Mac defaults use_setproctitle to False.
# source_paths = the importable package (so the mutants/ working copy can
# import it); only_mutate narrows the actual mutation to the touched file;
# also_copy brings the test tree (mutmut's default copies tests/ and test/
# but NOT tests_py/). For a per-change run, repoint only_mutate + the test
# selection at the changed file and its test.
source_paths = ["mcp_server"]
only_mutate = ["mcp_server/shared/json_native.py"]
also_copy = ["tests_py", "deps"]
pytest_add_cli_args_test_selection = ["tests_py/shared/test_json_native.py"]
use_setproctitle = false
45 changes: 45 additions & 0 deletions scripts/mutation_check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Scoped mutation-testing run (mutmut 3.x) — rules/coding-standards.md §12.
# Mutates ONLY the given source file(s) against the given test path, then
# restores pyproject.toml's [tool.mutmut] block untouched. Standard: 0
# surviving non-equivalent mutants on the changed code.
#
# usage: scripts/mutation_check.sh <test_path> <source.py> [source.py ...]
# e.g. scripts/mutation_check.sh tests_py/shared/test_json_native.py \
# mcp_server/shared/json_native.py
#
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

if [ $# -lt 2 ]; then
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit 2
fi

TESTS="$1"; shift
PY="$ROOT/pyproject.toml"
BAK="$(mktemp)"
cp "$PY" "$BAK"
cleanup() { cp "$BAK" "$PY"; rm -f "$BAK"; rm -rf "$ROOT/mutants" "$ROOT/.mutmut-cache"; }
trap cleanup EXIT

# Repoint only_mutate + the test selection at the change under test. The rest
# of [tool.mutmut] (source_paths, also_copy, use_setproctitle) is preserved.
python3 - "$PY" "$TESTS" "$@" <<'PYEOF'
import re, sys
path, tests, *sources = sys.argv[1], sys.argv[2], *sys.argv[3:]
fmt = lambda xs: "[" + ", ".join(f'"{x}"' for x in xs) + "]"
src = open(path).read()
src, n1 = re.subn(r'^only_mutate = .*$', "only_mutate = " + fmt(sources), src, count=1, flags=re.M)
src, n2 = re.subn(r'^pytest_add_cli_args_test_selection = .*$',
"pytest_add_cli_args_test_selection = " + fmt([tests]), src, count=1, flags=re.M)
assert n1 and n2, "pyproject [tool.mutmut] must define only_mutate and pytest_add_cli_args_test_selection"
open(path, "w").write(src)
PYEOF

echo ">>> mutating: $* | tests: $TESTS"
uv run mutmut run
echo ">>> survivors (must be empty, or documented equivalents):"
uv run mutmut results | grep -iE 'survived' || echo " none — 0 surviving mutants 🎉"
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/cdeust/Cortex",
"source": "github"
},
"version": "3.24.0",
"version": "3.24.1",
"packages": [
{
"registry_type": "pypi",
"identifier": "hypermnesia-mcp",
"version": "3.24.0",
"version": "3.24.1",
"transport": {
"type": "stdio"
}
Expand Down
51 changes: 51 additions & 0 deletions tests_py/server/test_handler_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

import asyncio
import json
import typing

import pytest
Expand Down Expand Up @@ -107,3 +108,53 @@ def test_tools_with_output_schema_have_dict_return_type(all_registered_tools):
"FastMCP will reject these at runtime (issue #17):\n"
+ "\n".join(f" - {name}: {rt!r}" for name, rt in offenders)
)


class TestWireValuesAreJsonNative:
"""Issue #17 part 2 (2026-06-23): the dict-return contract is necessary
but not sufficient. A handler can return a dict whose VALUES are not
JSON-native — the PostgreSQL store yields ``numpy.float32`` scores and
``datetime`` timestamps where the SQLite store yields ``float``/``str``.
FastMCP can only build ``structuredContent`` from JSON-serializable
values, so a non-native field silently drops structuredContent and the
Claude Code client rejects the call ("outputSchema defined but no
structured output returned"). This passed CI because the suite ran on
SQLite (native types) and asserted key presence, never serializability.

``safe_handler`` normalizes at the one boundary every handler crosses,
so every backend's output is JSON-native and identical in type. Pinned
here with a fake PG-shaped handler — no DB, backend-agnostic, fails
regardless of which store the suite runs against.
"""

def test_safe_handler_renders_pg_like_output_json_serializable(self):
import datetime as dt

import numpy as np

from mcp_server.tool_error_handler import safe_handler

async def pg_like_handler(_args):
# Mirrors a PostgreSQL recall row exactly: numpy score + tz-aware
# datetime. Raised "Object of type float32 is not JSON
# serializable" before the boundary normalizer existed.
return {
"memories": [
{
"memory_id": np.int64(4202320),
"score": np.float32(0.0026),
"created_at": dt.datetime(
2026, 6, 10, 13, 19, 31, tzinfo=dt.timezone.utc
),
}
],
"count": 1,
}

result = asyncio.run(safe_handler(pg_like_handler, {"query": "x"}))
# The exact wire requirement FastMCP imposes on structuredContent.
json.dumps(result) # must not raise
mem = result["memories"][0]
assert isinstance(mem["score"], float)
assert isinstance(mem["created_at"], str)
assert isinstance(mem["memory_id"], int)
Loading
Loading