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
58 changes: 54 additions & 4 deletions continuityos/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ def __init__(
r15_controller_profile_sha256: str = "",
r15_custody_path: str = "",
):
# Match core cos: optional model construction requires explicit opt-in.
# The existing CURRENT wrapper denies before this constructor is entered.
embedder_mode = os.environ.get("CONTINUITYOS_EMBEDDER", "").strip().lower()
if embedder_mode not in {
"", "hash", "hashing", "offline", "local", "fast", "fastembed",
}:
raise ValueError("UNSUPPORTED_EMBEDDER_MODE")
resolved = resolve_memory_db(db)
db = resolved["path"]
self.db_path = db
Expand All @@ -120,11 +127,54 @@ def __init__(
and db != ":memory:"
and not os.path.isfile(db)
)
embedder = None
if embedder_mode in {"fast", "fastembed"}:
try:
from .embedders import FastEmbedEmbedder
embedder = FastEmbedEmbedder()
except Exception as exc:
raise RuntimeError(
"FAST_EMBEDDER_UNAVAILABLE: explicit fast mode cannot fall back to hash"
) from exc
# Memory errors must propagate, not reopen the same DB with another model.
self.m = Memory(db, embedder=embedder)
try:
from .embedders import FastEmbedEmbedder
self.m = Memory(db, embedder=FastEmbedEmbedder())
except Exception:
self.m = Memory(db) # fallback to HashingEmbedder
# Synthetic probe only: never send a stored record to the embedder here.
try:
probe = self.m.embed("continuityos:mcp-embedding-dimension-probe:v1")
except Exception as exc:
raise ValueError("EMBEDDER_PROBE_FAILED") from exc
if not isinstance(probe, (list, tuple)) or not probe:
raise ValueError("EMBEDDER_PROBE_INVALID")
selected_dimension = len(probe)
# All non-NULL vectors, across namespaces; no text or BLOB payload reads.
cursor = self.m.store.con.execute(
"SELECT MIN(length(vec)), MAX(length(vec)), "
"COALESCE(SUM(CASE WHEN typeof(vec) <> 'blob' "
"OR length(vec) = 0 OR length(vec) % 4 <> 0 "
"THEN 1 ELSE 0 END), 0) FROM items WHERE vec IS NOT NULL"
)
try:
minimum_bytes, maximum_bytes, malformed = cursor.fetchone()
finally:
cursor.close()
if malformed:
raise ValueError("EMBEDDER_VECTOR_DATA_INVALID: non-empty float32 BLOB required")
if minimum_bytes is not None and (
minimum_bytes != maximum_bytes or minimum_bytes != selected_dimension * 4
):
stored = str(minimum_bytes // 4)
if minimum_bytes != maximum_bytes:
stored += ".." + str(maximum_bytes // 4) + " (mixed)"
raise ValueError(
f"EMBEDDER_DIMENSION_MISMATCH store={stored} selected={selected_dimension}; "
"set CONTINUITYOS_EMBEDDER to the original compatible backend; "
"restore or re-embed separately, never mix vector spaces"
)
except BaseException:
# A failed startup must not leave its opened Memory connection alive.
self.m.store.con.close()
raise
self.c = Continuity(memory=self.m)
self.c._context_source = db_source or resolved["source"]
self._governance_context_error = (
Expand Down
95 changes: 95 additions & 0 deletions docs/MCP_EMBEDDER_COMPATIBILITY_R2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# MCP embedding compatibility R2

Status: local corrective candidate; no activation or production admission.
Base: `1c9f8af5c2ecab74600f1171826fea2dc52397e6`, the reviewed PR #215 head.
This document supersedes only the R1 loader-failure fallback and startup
compatibility behavior. `MCP_OFFLINE_EMBEDDER_R1.md` remains the historical R1
contract; it has not been silently rewritten.

## Findings and scope

The owner-delivered Claude/Opus review identified two activation blockers.
Synthetic execution reproduced both: the R1 MCP default against a 384-D store
admitted a new 256-D row; a subsequent 384-D backend still failed when recall
included both dimensions. Explicit fast constructor failure could silently select
Hashing, even in an empty database. One-row sampling can miss existing mixtures.

This correction covers startup on the existing MCP path. It is not a Store write
guard, an embedder identity registry, a new runtime, or a database migration.

## Selection and failure contract

Mode normalization and the R1 aliases are unchanged. Default, empty, whitespace,
hash/hashing/offline/local do not import or construct FastEmbed. Only explicit
fast/fastembed selects the optional loader. Unknown modes raise
`UNSUPPORTED_EMBEDDER_MODE` before DB resolution. CURRENT containment retains
precedence; its implementation is unchanged.

**Intentional R1 contract change:** an explicitly selected FastEmbed import or
constructor failure raises `FAST_EMBEDDER_UNAVAILABLE`. No Hashing fallback,
warning-only degradation, or second Memory-open attempt is performed. The chained
exception retains the original failure. Path resolution may already have happened,
but Memory is not constructed on this failure path.

## Dimension barrier

After the single Memory construction, and before Continuity/Twin/control-plane
initialization or any JSON-RPC response, the server:

1. Calls the selected embedder once with the fixed synthetic probe
`continuityos:mcp-embedding-dimension-probe:v1`. No stored record is supplied.
Exceptions produce `EMBEDDER_PROBE_FAILED`; a non-list/tuple or empty result
produces `EMBEDDER_PROBE_INVALID`.
2. Issues one SELECT for minimum/maximum vector byte length and a malformed count
across all non-NULL `items.vec`. No text, namespace, or BLOB payload is returned
to Python. Equal valid float32 widths imply a uniform stored dimension.
Empty and NULL-only stores impose no stored-dimension constraint.
3. Rejects non-BLOB, empty, or non-float32-aligned payloads with
`EMBEDDER_VECTOR_DATA_INVALID`. Mixed or incompatible widths produce
`EMBEDDER_DIMENSION_MISMATCH store=<dimension-or-range> selected=<dimension>`
and an operator hint. No records are deleted or repaired.
4. Closes the opened Memory connection and propagates any failure in this new
validation block. Query failures remain failures; they do not mean empty store.

The aggregate reads all vector metadata and returns one row. Its scan cost grows
with store size. A mixed range in the error is min/max, not an enumeration of every
intermediate size. Vector lengths are float32 BLOB bytes divided by four.

Memory retains its existing initialization behavior, including WAL/schema work
where applicable. This check is before serving and adding a memory item, not a
claim that constructing arbitrary legacy Memory has zero filesystem effects.
A newly created empty database can exist if the later probe fails. No new schema,
re-embedding, automatic migration or repair is introduced.

## Compatibility and activation

HashingEmbedder uses 256 dimensions; the current FastEmbed bge-small backend uses
384. A 384-D database started in default/hash mode now fails before the first MCP
write. Switching back to fast does not repair already mixed rows: that store also
fails startup until separately restored or rebuilt under an approved procedure.

Vector length alone must not be used to infer backend identity. Two 256-D models
can inhabit different coordinate spaces. The barrier checks dimensions and BLOB
shape, not model identity, numeric finiteness, quality, provenance or authority.
Explicit fast mode may initialize a real optional model and then run the probe;
qualification tests use synthetic backends with network denied, not real models.

## Limits and regression contract

This is a startup snapshot, not a cross-process lock or continuous write invariant.
A raw Memory/Store writer can insert incompatible data after startup. A backend
whose output shape changes later is not constrained by this initial probe.
Core cos selection, Memory/Store/index, R14/R15, Person R21H and A4/A3 are unchanged.
Shared mode selection and full embedder identity are separate proposals.

R2 explicitly replaces the two parameterized R1 loader-failure fallback cases
with fail-closed expectations. Other original test bodies are preserved; the stdio
utility gains an optional synthetic import-failure mode. New tests cover all-row
versus one-row checking, order/namespace independence, malformed BLOBs, probe/query
errors and connection closure, compatible stores, fast failure, and real stdio
refusal before initialize/remember responses.

The prior Opus REVISE supports the findings; it is not approval of this correction.
Independent review and a new official wheel/CI remain required. No push, PR edit,
merge, deployment, runtime switch, real-user-data access, model download or trading
is part of this local work.
100 changes: 100 additions & 0 deletions docs/MCP_OFFLINE_EMBEDDER_R1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# MCP offline-first embedder selection — R1

Specification status: RC. Scope: the existing stdio MCP server, not a replacement
runtime, permission issuer, model router, schema migration, or OS sandbox.
Predecessor: `34d02e44104146beba7b174a88d58270606891fd` (A4/A3, PR #214).
Implementation and test qualification belong to the exact local commit and its
separate evidence manifest; this document is not production admission.

## Why this change

The predecessor constructed `FastEmbedEmbedder()` on every `Server` startup and
fell back to `Memory(db)` after any exception in that block. This included calls
with `CONTINUITYOS_EMBEDDER=hash` or `offline`. Core `cos` already requires explicit
opt-in for optional model construction. A synthetic constructor sentinel and
actual stdio child processes reproduce the difference without a real model.

## Selection contract

The server samples `CONTINUITYOS_EMBEDDER` once on construction, strips surrounding
whitespace, and lowercases it. It does not cache the setting process-wide or
change the embedder of an already-created server.

| Environment value | Behavior |
| --- | --- |
| Unset, empty, whitespace only | `Memory` uses its local `HashingEmbedder` default |
| `hash`, `hashing`, `offline`, `local` (case-insensitive) | Same local default; no optional constructor |
| `fast`, `fastembed` (case-insensitive) | Explicit opt-in: attempt the existing optional constructor |
| Any other value | Raise `ValueError("UNSUPPORTED_EMBEDDER_MODE")` before DB resolution/opening |

Offline modes do not import `continuityos.embedders` on this startup branch or
instantiate FastEmbed. Other application code may independently import modules;
this is not a blanket import prohibition.

For explicit fast opt-in only, constructor/import failure retains the prior
fallback to the local default. This does **not** prove that an existing vector
index is compatible with a different embedder, and is not a migration mechanism.
An operator relying on FastEmbed must explicitly select it and separately verify
backend availability and the matching existing embedding space.

`Memory` is constructed exactly once after selection. An exception from Memory
initialization is propagated; it must not trigger reopening the same database
with another embedder. Constructor-failure fallback is not permission to bypass
Memory, store, path, or effect guards.

## CURRENT and authority boundaries

The existing direct-surface wrapper remains outside `Server.__init__`. A declared
CURRENT binding must still pass the existing boundary before startup. Invalid or
incomplete declarations cannot become a legacy fallback. Module execution via
`python -m continuityos.mcp_server` remains subject to its existing guard.

No CURRENT checker, effect ceiling, R14 witness, R15 runtime binding, policy
snapshot, database resolver, broker registry, transaction, execution path, or
JSON-RPC tool schema is changed. Unknown embedder mode is validated before DB
resolution; the previously loaded policy is never swapped to make a test pass.

`fast` is a choice of optional loader, not authorization for a forbidden current
session, real personal-memory disclosure, trading, network access, or production.
The broader mandatory-path/OS-isolation gate remains unqualified.

## Verification

`tests/test_product_mcp_offline.py` covers:

- Default/offline aliases with a constructor sentinel; fast aliases with a
recording fake backend; constructor-failure fallback.
- Invalid mode before resolver, Memory, loader, or database effect; existing
synthetic database preservation on rejection.
- One Memory construction even when it fails; separate instances independently
sample mode; exact existing DB and one policy snapshot reach the broker.
- Declared-current rejection for direct construction and `main`, including
unknown mode and fast opt-in.
- Actual stdio process startup, remembering a synthetic marker, and rejection
before protocol/DB initialization; a local fake `fastembed` module makes both
optional import and construction observable without downloading a model.

The same new test bodies are run against the unchanged predecessor first and
then against the patch. Existing broker/MCP tests and current-boundary/Person
regressions remain unmodified. Optional-available qualification uses a synthetic
sentinel package, not a live ONNX model. Optional-absent qualification uses a
minimal disposable Python environment with no shared site-packages or pip fetch.
A test-local Python audit denies socket creation, DNS and connections, allowing
only local hostname lookup for JUnit metadata. It is not OS-level isolation.

## Compatibility and limits

The default choice changes on hosts with FastEmbed installed: installing an
optional dependency alone no longer activates it in MCP. Existing FastEmbed users
must set `CONTINUITYOS_EMBEDDER=fast` or `fastembed`. No records are re-embedded,
no vector metadata is silently rewritten, and no active installation is switched
by publishing or testing this source change.

The plain Python API raises on unknown mode. The unbound stdio entry point exits
nonzero before serving JSON-RPC; it does not emit a successful initialization or
silently substitute hash/fast. Existing bound-session errors retain precedence.

No live-model/download, real-data, cross-machine, packaged-release, or independent
Opus qualification is claimed by the local sentinel tests. No push, PR change,
merge, deployment, scheduler, service, TPM, OS account, or ACL operation is part
of this local work order. `C0 HOLD`; `NOT_PRODUCTION_ADMITTED`.
Loading
Loading