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: 0 additions & 4 deletions .bestpractices.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@

"test_status": "Met",
"test_justification": "An automated test suite lives under tests_py/ (current size tracked by assets/badge-tests.svg, regenerated from a live 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py",

"test_invocation_status": "Met",
"test_invocation_justification": "The whole suite runs with a single 'pytest' command, documented in CONTRIBUTING.md under Testing along with per-layer subsets: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md",

Expand All @@ -115,7 +114,6 @@

"tests_are_added_status": "Met",
"tests_are_added_justification": "New functionality ships with its tests; the suite has grown continuously alongside the v4.x feature series (current size: assets/badge-tests.svg), and CI runs it on every pull request.",

"tests_documented_added_status": "Met",
"tests_documented_added_justification": "The requirement is written into the documented instructions for change proposals: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory states that every change adding or altering observable behaviour must arrive with tests in the same PR, the per-tool and per-mechanism checklists repeat it as a concrete step, and .github/PULL_REQUEST_TEMPLATE.md requires a Test plan section in every pull request.",

Expand Down Expand Up @@ -190,7 +188,6 @@

"dynamic_analysis_status": "Unmet",
"dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs its automated test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.",

"dynamic_analysis_unsafe_status": "N/A",
"dynamic_analysis_unsafe_justification": "Cortex is written in Python, a memory-safe language, so the memory-safety tooling this criterion asks about (ASan, Valgrind) does not apply.",

Expand Down Expand Up @@ -298,7 +295,6 @@

"automated_integration_testing_status": "Met",
"automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: the current-sized suite (assets/badge-tests.svg) on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.",

"regression_tests_added50_status": "Met",
"regression_tests_added50_justification": "Measured on 2026-07-27 over the merged pull requests titled as fixes in the preceding six months (2026-01-27 onward): 23 of 30 \u2014 76.7% \u2014 changed the test tree in the same PR, against the 50% this criterion asks for. (The measure counts a PR as carrying tests when it touches tests_py/ or tests_js/, which is a proxy for 'added a regression test'; the policy behind it is written down at https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory \u2014 a bug fix carries a regression test that fails on the pre-fix code.)",

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed
- **"Docker Smoke" intermittently reported no `tools/list` response with no exception and no JSON-RPC error frame** — reproduced on `main` (CI run 30504042295: attempt 1 failed, attempt 2 succeeded, same commit `56f2f4f`, no code change), on PR #254 and PR #266. Root cause is upstream: `mcp` 1.29.0's `BaseSession._receive_loop` (`mcp/shared/session.py`) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (`tools/list` after `initialize`) is still running in its own task and has not called `respond()` yet — `mcp.server.lowlevel.server.Server._handle_request` catches the resulting `ClosedResourceError` and logs it via `logger.debug()` on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured `parsed_count == 3` on every trial, pass and fail alike) — nothing is ever left unread; only the already-computed response is lost. `fastmcp` 3.4.5's `LowLevelServer.run` override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: `mcp_server/infrastructure/stdio_transport.py` interposes a write-stream proxy that no-ops the SDK's premature `aclose()` and closes the real stream only after the low-level server's `run()` call has returned — which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. `mcp_server/__main__.py::main()` now drives stdio through this wrapper instead of `mcp.run(transport="stdio")` directly. Regression test at the SDK boundary (`tests_py/infrastructure/test_stdio_transport.py`): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives — verified to fail against the pre-fix code (bypassing the guard reproduces the exact original symptom: `initialize` answered, `tools/call` silently missing). A scoped mutation run (`scripts/mutation_check.sh`) against the new module found 9 further survivors and 22 uncovered mutants; hardened in `tests_py/infrastructure/test_stdio_transport_wiring.py` (the `stateless` parameter's actual MCP-lifecycle effect, and the outer `run_stdio_drained` wrapper's banner/transport-context-var/log-message wiring) and `_stdio_transport_helpers.py` (shared fixtures, split out to keep both files under the 500-line cap) — final scoped mutation score: 42/43 killed, 1 documented-equivalent (`typing.cast`'s type argument is never read at runtime, same argument as `json_native.py`'s below). `scripts/docker_smoke.sh` gains a second, independent hardening: its `timeout`/`gtimeout` wrapper was itself measured (2026-07-30) not to reliably stop a genuinely hung container (SIGTERM to the `docker run` CLIENT process does not reliably reach the CONTAINER) — the container could outlive its supposed 60s bound indefinitely. A `--cidfile`-based watchdog now `docker kill`s the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) — both directions proven stable across repeated runs, on both the `timeout`-available and no-timeout-binary code paths. Boy-scout: `MIN_TOOL_COUNT`'s default and source comment had drifted to 49 (citing a test name, `test_standalone_baseline_is_49_tools`, that no longer exists) against the true current baseline of 52 (`tests_py/test_main.py::test_standalone_baseline_is_52_tools`) — the gate's floor was silently weaker than it should have been by three tools' worth of regression headroom; corrected in the same change. Review found one more divergence before merge: `run_stdio_drained`'s `show_banner` defaulted to a hardcoded `True` rather than resolving `fastmcp.settings.show_server_banner` the way the composition root's replaced call (`mcp.run(transport="stdio")`, via `TransportMixin.run_async`) does — a user who disabled the banner via `FASTMCP_SHOW_SERVER_BANNER=false` got it printed on stderr on every stdio launch regardless. Fixed by defaulting `show_banner` to `None` and resolving the setting at that point, exactly where `run_async` does, so an explicit argument still overrides it; pinned in both directions by `tests_py/infrastructure/test_stdio_transport_wiring.py`. Review found a second, size-only finding: the banner fix's docstring/citation additions pushed `run_stdio_drained` to 54 lines, over both the hard `§4.2` 50-line cap and this repo's own 40-line/method `CLAUDE.md` convention. Behavior-preserving refactor (Fowler 2018 Ch. 6, Extract Function): the banner resolution and its sourced citation move into a new `_resolve_show_banner()` helper (27 lines); the same pattern is applied to `_run_low_level_drained`, which the same measurement pass found already at 57 lines, by extracting the `mcp._mcp_server.run()` call and its `cast()`-equivalence citation into `_run_mcp_with_guarded_stream()` (37 lines) — both public functions land at 39 lines, no test added or modified, same 14/14 targeted + 732/732 (5 skipped) infrastructure-suite pass counts before and after. While relocating the citation, the two upstream line-number references it carried (`fastmcp/server/mixins/transport.py` `L56-57`/`L184-186`) were verified against the actually-installed `fastmcp==3.4.5` in `.venv` and found to be a consistent −32-line offset from the real `run_async`/`run_stdio_async` locations (`L88-89`/`L216-218`); corrected in place rather than carried forward unchecked.

### Security
- **`transformers` 4.57.6 → 5.14.1, closing all 30 open Dependabot alerts** (#257; supersedes Dependabot's #255/#256). Three advisories require `>= 5.5.0`: GHSA-fgcw-684q-jj6r (high), GHSA-29pf-2h5f-8g72 (high), GHSA-69w3-r845-3855 (medium). `transformers` is not a direct dependency — it arrives through `sentence-transformers` — so the bump belongs in `uv.lock`, the single source of truth from which `scripts/generate_pip_constraints.py` exports every `requirements/*.txt`. Dependabot edited only the exported files, which left `huggingface-hub` at 0.36.2 against transformers 5's `>=1.5.0,<2.0` floor; that is a `ResolutionImpossible` on every job that installs dependencies, and a lock/export disagreement on Lint. Relocking moves the cluster coherently: `huggingface-hub` 0.36.2 → 1.25.1, `datasets` 2.14.4/5.0.1 → 5.0.1 (the `<3.11` fork existed only to hold `huggingface-hub<1.0`), plus `typer`/`shellingham`/`annotated-doc` as new transitive dependencies of transformers 5 — every one `requires-python >= 3.10`, matching this project's floor. `sentence-transformers` stays 5.6.1 (it declares `transformers<6.0.0,>=4.41.0`) and FlashRank is untouched (it depends on `tokenizers`/`onnxruntime`, never on transformers). **`TRANSFORMERS_OFFLINE` keeps working**: transformers 5 no longer reads it, but `huggingface_hub` 1.25.1 does (`HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE") or os.environ.get("TRANSFORMERS_OFFLINE"))`), so CI's offline test runs are unaffected.
- **The embedding model's end-to-end behaviour is now asserted, not assumed** (#257). Every prior embedding test mocked `SentenceTransformer`, so a dependency bump could break the real model without turning a single test red — and two mechanisms would have hidden it: `_finalize_loaded` silently overwrites the requested dimension with whatever the model reports, and every non-`LOADED` state degrades to the algorithmic fallback, which also returns 384-dim L2-normalised vectors. `tests_py/infrastructure/test_embedding_live_contract.py` loads the real `all-MiniLM-L6-v2` through the production engine and asserts neural provenance (`ModelState.LOADED`, `mode == "neural"`), dimension 384, determinism across engine instances, batch/single agreement, and that paraphrases outrank unrelated text. It **fails** rather than skips when the model is unavailable under `CI`, where the pre-download step guarantees the weights.
Expand Down
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ coding write gates, causal graphs, and intent-aware retrieval.
resolving from the `pyproject.toml` ranges instead gives you versions CI never
had (issue #253).
- Environment preflight: `python -m mcp_server.doctor` (backend-aware check list, fix message per check)
- Tests: `pytest` (full suite — current count: `assets/badge-tests.svg`, or run `pytest --collect-only -q`) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing`
- Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough.
- Tests: `pytest` (full suite — current count: `assets/badge-tests.svg`, or run `pytest --collect-only -q`) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing`- Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough.
- Type gate (pyright, zero-diagnostic): resolve its environment from `uv.lock`
(`uv sync --no-default-groups --extra … --group typecheck`), never from the
`pyproject.toml` ranges — a range-resolved env reads a different type surface
Expand Down
4 changes: 1 addition & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ uvx --python 3.13 --from "hypermnesia-mcp[postgresql]" cortex-doctor

# Run tests (under tests_py/; current count: assets/badge-tests.svg)
pytest

# Run a benchmark
python benchmarks/longmemeval/run_benchmark.py --variant s
```
Expand Down Expand Up @@ -164,8 +163,7 @@ The full standard lives in

```bash
pytest # full suite (see assets/badge-tests.svg for the current count)
pytest tests_py/core # core (pure business logic) only
pytest tests_py/integration # PostgreSQL-backed integration
pytest tests_py/core # core (pure business logic) onlypytest tests_py/integration # PostgreSQL-backed integration
pytest tests_py/benchmarks -k locomo # subset
pytest -x --ff # stop on first fail, run failures first
```
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
<a href="LICENSE"><img src="assets/badge-license.svg" alt="License: MIT"></a>
<img src="assets/badge-python.svg" alt="Python 3.10+">
<img src="assets/badge-tests.svg" alt="tests passing">
<img src="assets/badge-references.svg" alt="97 referenced papers">
<img src="assets/badge-version.svg" alt="Version 4.16.0">
<img src="assets/badge-references.svg" alt="97 referenced papers"> <img src="assets/badge-version.svg" alt="Version 4.16.0">
<a href="https://www.bestpractices.dev/projects/13836"><img src="https://www.bestpractices.dev/projects/13836/badge" alt="OpenSSF Best Practices"></a>
<a href="https://mcptoplist.com/server/io.github.cdeust%2Fhypermnesia-mcp"><img src="assets/badge-mcp-toplist.svg" alt="MCP Toplist: Top 1.2% of 81,919 tracked MCP servers, July 2026"></a>
</p>
Expand Down Expand Up @@ -527,8 +526,7 @@ Cortex is **local-first**: your memories, conversations, and profiles stay on yo

```bash
pytest # full suite (see assets/badge-tests.svg for the current count)
ruff check . # Lint
ruff format --check . # Format
ruff check . # Lintruff format --check . # Format
python scripts/check_doc_claims.py # advertised counts must match the repo
```

Expand Down
8 changes: 4 additions & 4 deletions assets/badge-tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion docs/ASSURANCE-CASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ Actions) on every push and pull request plus weekly, currently 0 open alerts;
OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and the full automated
test suite (`assets/badge-tests.svg` carries the current count) runs on four
Python versions, two backends and Windows.

## 6. What this assurance case does NOT claim

- **It does not claim freedom from defects.** Provenance proves who built an
Expand Down
11 changes: 10 additions & 1 deletion mcp_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
file=sys.stderr,
)

import anyio
from fastmcp import FastMCP

from mcp_server import (
Expand All @@ -64,6 +65,7 @@
from mcp_server.infrastructure.config import WIKI_ROOT
from mcp_server.infrastructure.mcp_client_pool import close_all
from mcp_server.infrastructure.otel_exporter import build_otel_exporter
from mcp_server.infrastructure.stdio_transport import run_stdio_drained
from mcp_server.infrastructure.upstream_availability import (
codebase_upstream_available,
prd_upstream_available,
Expand Down Expand Up @@ -192,7 +194,14 @@ def _shutdown(sig=None, frame=None) -> None:
def main() -> None:
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
mcp.run(transport="stdio")
# NOT mcp.run(transport="stdio"): that delegates to FastMCP's
# run_stdio_async, which closes the write stream on stdin-EOF before an
# in-flight request's handler has had a chance to respond (see
# mcp_server/infrastructure/stdio_transport.py's module docstring for
# the exact upstream race + citations). run_stdio_drained is a drop-in
# replacement with the same banner/lifespan/init-options behavior that
# additionally drains in-flight handlers before shutdown.
anyio.run(run_stdio_drained, mcp)


if __name__ == "__main__":
Expand Down
Loading