lean-memory-console 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ """Docker-mode streamable-HTTP MCP mount (/mcp) with bearer auth.
2
+
3
+ The wrapper FastMCP is built stateless (json_response) so its Starlette app
4
+ mounts into FastAPI without needing to own the server process. A thin ASGI
5
+ wrapper enforces ``Authorization: Bearer <api_key>`` before delegating; full
6
+ MCP-over-HTTP round-trips are exercised in the manual E2E (and, since the review,
7
+ in a two-request automated test).
8
+
9
+ Two SDK-1.28.1 decisions, both disclosed:
10
+
11
+ 1. Session-manager lifecycle — LIFESPAN ONLY.
12
+ ``streamable_http_app().handle_request`` raises
13
+ ``RuntimeError("Task group is not initialized. Make sure to use run().")``
14
+ until ``StreamableHTTPSessionManager.run()`` has been entered — even in
15
+ stateless mode. ``run()`` may be entered EXACTLY ONCE per instance: it sets
16
+ ``_has_started`` permanently and raises on a second call, and on exit it
17
+ resets ``_task_group`` to ``None`` (verified in the installed SDK at
18
+ ``mcp/server/streamable_http_manager.py``). A per-request self-start is
19
+ therefore broken beyond the first request. We expose ``session_manager`` and
20
+ the console's app lifespan (``app.create_app``) enters ``run()`` once for the
21
+ process lifetime. Consequence: tests that hit ``/mcp`` MUST drive the lifespan
22
+ — use ``with TestClient(app) as client:`` (a bare ``TestClient(app)`` does not
23
+ run the lifespan and would 500 on the first authenticated request).
24
+
25
+ 2. Transport security — DNS-rebinding protection ENABLED with a loopback
26
+ default allow-list, extendable via ``LM_MCP_ALLOWED_HOSTS``.
27
+ FastMCP validates Host and Origin headers to defeat DNS rebinding. The SDK
28
+ couples both checks behind a single ``enable_dns_rebinding_protection`` flag
29
+ and offers no host wildcard — only exact matches and ``base:*`` port patterns
30
+ (see ``mcp/server/transport_security.py``). We enable protection and enumerate
31
+ the loopback host/origin values a legitimate local client presents. This gives
32
+ real Origin restriction (a cross-site Origin → 403) while the bearer gate in
33
+ front remains the primary access control.
34
+
35
+ The shipped ``deploy/docker-compose.yml`` publishes 8377 directly (no reverse
36
+ proxy in the stack), so a container reached over the LAN presents a Host like
37
+ ``192.168.1.10:8377`` or ``myserver:8377`` that the loopback default does not
38
+ cover — it would 421. Remote-host deployments therefore set
39
+ ``LM_MCP_ALLOWED_HOSTS`` (comma-separated host patterns, e.g.
40
+ ``192.168.1.10:*,myserver:*``, parsed in ``config.py``); those patterns are
41
+ ADDED to the loopback defaults here. Origins stay restricted to loopback. We
42
+ extend rather than disable the flag, because disabling it also disables the
43
+ Origin check — losing the one control that matters for a browser-originated
44
+ DNS-rebinding attack. The bearer gate remains the primary control regardless.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import secrets
50
+ from typing import Any
51
+
52
+ from mcp.server.fastmcp import FastMCP
53
+ from mcp.server.transport_security import TransportSecuritySettings
54
+
55
+ from ..config import ConsoleConfig
56
+ from ..engine import EngineGateway
57
+ from ..mcp_tools import register_maintenance_tools
58
+
59
+ # Loopback host/origin default allow-list for the inner MCP transport-security
60
+ # check. Host entries use the SDK's exact + ``base:*`` port-wildcard matching;
61
+ # there is no host wildcard, so remote deployments extend this list via
62
+ # LM_MCP_ALLOWED_HOSTS (see the module docstring, decision 2).
63
+ _DEFAULT_ALLOWED_HOSTS = [
64
+ "127.0.0.1",
65
+ "127.0.0.1:*",
66
+ "localhost",
67
+ "localhost:*",
68
+ "[::1]",
69
+ "[::1]:*",
70
+ ]
71
+ _ALLOWED_ORIGINS = [
72
+ "http://127.0.0.1",
73
+ "http://127.0.0.1:*",
74
+ "http://localhost",
75
+ "http://localhost:*",
76
+ ]
77
+
78
+
79
+ def _build_http_mcp(
80
+ gateway: EngineGateway, extra_allowed_hosts: list[str] | None = None
81
+ ) -> FastMCP:
82
+ # Loopback defaults + any operator-supplied patterns (LM_MCP_ALLOWED_HOSTS),
83
+ # de-duplicated while preserving order.
84
+ allowed_hosts = list(_DEFAULT_ALLOWED_HOSTS)
85
+ for host in extra_allowed_hosts or []:
86
+ if host not in allowed_hosts:
87
+ allowed_hosts.append(host)
88
+ mcp = FastMCP(
89
+ "lean-memory-console",
90
+ stateless_http=True,
91
+ json_response=True,
92
+ streamable_http_path="/",
93
+ transport_security=TransportSecuritySettings(
94
+ enable_dns_rebinding_protection=True,
95
+ allowed_hosts=allowed_hosts,
96
+ allowed_origins=_ALLOWED_ORIGINS,
97
+ ),
98
+ )
99
+
100
+ @mcp.tool()
101
+ async def memory_add(
102
+ namespace: str,
103
+ text: str,
104
+ source: str = "user",
105
+ t_ref: int | None = None,
106
+ ) -> dict[str, Any]:
107
+ """Ingest text into the namespace's memory (HTTP wrapper)."""
108
+ res = await gateway.add(namespace, text, source=source, t_ref=t_ref)
109
+ return {
110
+ "fact_ids": res.fact_ids,
111
+ "superseded_count": res.superseded_count,
112
+ }
113
+
114
+ @mcp.tool()
115
+ async def memory_search(
116
+ namespace: str, query: str, k: int = 5
117
+ ) -> dict[str, Any]:
118
+ """Search a namespace's memory (HTTP wrapper); always latest-only."""
119
+ res = await gateway.search(
120
+ namespace, query, k=k, latest_only=True, origin="agent"
121
+ )
122
+ return {
123
+ "hits": [
124
+ {"fact_text": h["fact_text"], "final_score": h["final_score"]}
125
+ for h in res.hits
126
+ ]
127
+ }
128
+
129
+ # The same four maintenance tools as the stdio surfaces (§6.3). No review prompt
130
+ # here — MCP prompts are a stdio-client capability; HTTP clients use the tools.
131
+ register_maintenance_tools(mcp, gateway)
132
+
133
+ return mcp
134
+
135
+
136
+ def build_mcp_mount(gateway: EngineGateway, config: ConsoleConfig):
137
+ """Return an ASGI app: bearer gate -> streamable-HTTP MCP app.
138
+
139
+ Mount at "/mcp"; the inner MCP app serves at its own root "/" once mounted
140
+ (FastMCP's ``streamable_http_path`` is "/" so it resolves at exactly the
141
+ mount point). The returned app exposes ``session_manager``; the caller MUST
142
+ enter its ``run()`` in the app lifespan (see module docstring, decision 1) —
143
+ there is no per-request fallback, because ``run()`` is once-only per instance.
144
+ """
145
+ mcp = _build_http_mcp(gateway, config.mcp_allowed_hosts)
146
+ inner = mcp.streamable_http_app()
147
+ session_manager = mcp.session_manager # initializes it on the inner app
148
+ # None when no api_key is configured, so the constant-time compare below
149
+ # always fails (compare_digest is skipped for a None expected value).
150
+ expected = f"Bearer {config.api_key}" if config.api_key else None
151
+
152
+ async def gated(scope, receive, send):
153
+ if scope["type"] != "http":
154
+ await inner(scope, receive, send)
155
+ return
156
+ headers = dict(scope.get("headers") or [])
157
+ auth = headers.get(b"authorization", b"").decode()
158
+ # Constant-time bearer compare; both sides must be non-None strings.
159
+ if expected is None or not secrets.compare_digest(auth, expected):
160
+ await _send_401(send)
161
+ return
162
+ await inner(scope, receive, send)
163
+
164
+ # Exposed so create_app can drive the once-only session manager via the app
165
+ # lifespan (the only supported start path).
166
+ gated.session_manager = session_manager
167
+ return gated
168
+
169
+
170
+ async def _send_401(send):
171
+ await send(
172
+ {
173
+ "type": "http.response.start",
174
+ "status": 401,
175
+ "headers": [
176
+ (b"content-type", b"application/json"),
177
+ (b"referrer-policy", b"no-referrer"),
178
+ ],
179
+ }
180
+ )
181
+ await send(
182
+ {
183
+ "type": "http.response.body",
184
+ "body": b'{"detail":"unauthorized"}',
185
+ }
186
+ )
@@ -0,0 +1,115 @@
1
+ """/views/*/review + /views/*/maintenance — the maintenance review-path router.
2
+
3
+ Every route proxies a single ``EngineGateway`` maintenance method (WP10a): the
4
+ engine is never opened directly here. Existence + reserved-namespace guarding is
5
+ identical to the read-path router (``_ns_db``), so a review call against a
6
+ nonexistent namespace 404s rather than creating the ``.db`` as a side effect.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Request
12
+ from fastapi.responses import JSONResponse
13
+ from pydantic import BaseModel
14
+
15
+ from ..config import is_reserved_namespace, ns_db_path
16
+
17
+
18
+ class DecideBody(BaseModel):
19
+ decision: str
20
+ edited_text: str | None = None
21
+
22
+
23
+ class PromoteBody(BaseModel):
24
+ fact_id: str
25
+
26
+
27
+ class RunBody(BaseModel):
28
+ apply: bool = False
29
+
30
+
31
+ # The lifecycle CAS reports a re-decided proposal as an outcome, not an
32
+ # exception (lifecycle.py:_already_decided) — surface those as 409 so the UI can
33
+ # refresh the row, with the informative body passed through verbatim.
34
+ _ALREADY_DECIDED = frozenset({"already_decided", "already_applied"})
35
+
36
+
37
+ def _ns_db(request: Request, namespace: str):
38
+ if is_reserved_namespace(namespace):
39
+ raise HTTPException(status_code=404, detail="unknown namespace")
40
+ config = request.app.state.config
41
+ path = ns_db_path(config.data_root, namespace)
42
+ if not path.exists():
43
+ raise HTTPException(status_code=404, detail="unknown namespace")
44
+ return path
45
+
46
+
47
+ def build_review_router() -> APIRouter:
48
+ # Imported here (not at module top) to break the app<->routes import cycle,
49
+ # exactly as build_views_router does: app.py imports this builder last, so
50
+ # require_auth is already defined by the time create_app calls it.
51
+ from ..app import require_auth
52
+
53
+ router = APIRouter(prefix="/views")
54
+
55
+ @router.get(
56
+ "/{namespace}/review/queue", dependencies=[Depends(require_auth)]
57
+ )
58
+ async def review_queue(
59
+ request: Request,
60
+ namespace: str,
61
+ kind: str | None = None,
62
+ limit: int = 20,
63
+ ):
64
+ _ns_db(request, namespace)
65
+ gateway = request.app.state.gateway
66
+ return await gateway.review_queue(namespace, kind=kind, limit=limit)
67
+
68
+ @router.post(
69
+ "/{namespace}/review/{proposal_id}/decide",
70
+ dependencies=[Depends(require_auth)],
71
+ )
72
+ async def decide(
73
+ request: Request,
74
+ namespace: str,
75
+ proposal_id: str,
76
+ body: DecideBody,
77
+ ):
78
+ _ns_db(request, namespace)
79
+ gateway = request.app.state.gateway
80
+ result = await gateway.decide(
81
+ namespace, proposal_id, body.decision,
82
+ edited_text=body.edited_text,
83
+ )
84
+ if result.get("outcome") in _ALREADY_DECIDED:
85
+ return JSONResponse(result, status_code=409)
86
+ return result
87
+
88
+ @router.post(
89
+ "/{namespace}/review/promote", dependencies=[Depends(require_auth)]
90
+ )
91
+ async def promote(request: Request, namespace: str, body: PromoteBody):
92
+ _ns_db(request, namespace)
93
+ gateway = request.app.state.gateway
94
+ return await gateway.promote(namespace, body.fact_id)
95
+
96
+ @router.get(
97
+ "/{namespace}/maintenance/status",
98
+ dependencies=[Depends(require_auth)],
99
+ )
100
+ async def maintenance_status(request: Request, namespace: str):
101
+ _ns_db(request, namespace)
102
+ gateway = request.app.state.gateway
103
+ return await gateway.maintenance_status(namespace)
104
+
105
+ @router.post(
106
+ "/{namespace}/maintenance/run", dependencies=[Depends(require_auth)]
107
+ )
108
+ async def maintenance_run(
109
+ request: Request, namespace: str, body: RunBody
110
+ ):
111
+ _ns_db(request, namespace)
112
+ gateway = request.app.state.gateway
113
+ return await gateway.maintain(namespace, apply=body.apply)
114
+
115
+ return router
@@ -0,0 +1,153 @@
1
+ """/views/* — the human read-path router."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Request
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ..config import is_reserved_namespace, ns_db_path
9
+ from .. import inspect_sql
10
+
11
+
12
+ class TestSearchBody(BaseModel):
13
+ query: str
14
+ k: int = Field(default=5, ge=1, le=200)
15
+
16
+
17
+ def _ns_db(request: Request, namespace: str):
18
+ if is_reserved_namespace(namespace):
19
+ raise HTTPException(status_code=404, detail="unknown namespace")
20
+ config = request.app.state.config
21
+ path = ns_db_path(config.data_root, namespace)
22
+ if not path.exists():
23
+ raise HTTPException(status_code=404, detail="unknown namespace")
24
+ return path
25
+
26
+
27
+ def build_views_router() -> APIRouter:
28
+ # Imported here (not at module top) to break the app<->views import cycle:
29
+ # app.py imports build_views_router as its last import, so require_auth is
30
+ # already defined by the time this function runs (create_app calls it).
31
+ from ..app import require_auth
32
+
33
+ router = APIRouter(prefix="/views")
34
+
35
+ @router.get("/whoami")
36
+ def whoami(request: Request):
37
+ config = request.app.state.config
38
+ from ..app import _is_authenticated
39
+ from ..engine import resolved_models_mode
40
+
41
+ auth = "bearer" if config.mode == "docker" else "token"
42
+ return {
43
+ "mode": config.mode,
44
+ "auth": auth,
45
+ "authenticated": _is_authenticated(request, config),
46
+ "data_root": str(config.data_root),
47
+ # Resolved retrieval-backend mode ("real"|"stub"), NOT the raw env:
48
+ # "stub" whenever the built Memory uses deterministic offline
49
+ # scores, so the UI can warn that semantic scores are stub-generated.
50
+ "models": resolved_models_mode(config),
51
+ }
52
+
53
+ @router.get("/namespaces", dependencies=[Depends(require_auth)])
54
+ def namespaces(request: Request):
55
+ config = request.app.state.config
56
+ event_log = request.app.state.event_log
57
+ return inspect_sql.list_namespaces(config.data_root, event_log)
58
+
59
+ @router.get("/{namespace}/facts", dependencies=[Depends(require_auth)])
60
+ def facts(
61
+ request: Request,
62
+ namespace: str,
63
+ latest_only: bool = True,
64
+ predicate: str | None = None,
65
+ entity: str | None = None,
66
+ min_salience: float | None = None,
67
+ q: str | None = None,
68
+ page: int = 1,
69
+ page_size: int = 50,
70
+ ):
71
+ path = _ns_db(request, namespace)
72
+ return inspect_sql.list_facts(
73
+ path,
74
+ latest_only=latest_only,
75
+ predicate=predicate,
76
+ entity=entity,
77
+ min_salience=min_salience,
78
+ q=q,
79
+ page=page,
80
+ page_size=page_size,
81
+ )
82
+
83
+ @router.get(
84
+ "/{namespace}/facts/{fact_id}", dependencies=[Depends(require_auth)]
85
+ )
86
+ def fact_detail(request: Request, namespace: str, fact_id: str):
87
+ path = _ns_db(request, namespace)
88
+ fact = inspect_sql.get_fact(path, fact_id)
89
+ if fact is None:
90
+ raise HTTPException(status_code=404, detail="unknown fact")
91
+ return fact
92
+
93
+ @router.get("/{namespace}/episodes", dependencies=[Depends(require_auth)])
94
+ def episodes(
95
+ request: Request, namespace: str, page: int = 1, page_size: int = 50
96
+ ):
97
+ path = _ns_db(request, namespace)
98
+ return inspect_sql.list_episodes(path, page=page, page_size=page_size)
99
+
100
+ @router.get(
101
+ "/{namespace}/episodes/{episode_id}",
102
+ dependencies=[Depends(require_auth)],
103
+ )
104
+ def episode_detail(request: Request, namespace: str, episode_id: str):
105
+ path = _ns_db(request, namespace)
106
+ ep = inspect_sql.get_episode(path, episode_id)
107
+ if ep is None:
108
+ raise HTTPException(status_code=404, detail="unknown episode")
109
+ return ep
110
+
111
+ @router.get("/{namespace}/entities", dependencies=[Depends(require_auth)])
112
+ def entities(
113
+ request: Request, namespace: str, page: int = 1, page_size: int = 50
114
+ ):
115
+ path = _ns_db(request, namespace)
116
+ return inspect_sql.list_entities(path, page=page, page_size=page_size)
117
+
118
+ @router.get("/{namespace}/events", dependencies=[Depends(require_auth)])
119
+ def events(
120
+ request: Request,
121
+ namespace: str,
122
+ kind: str | None = None,
123
+ page: int = 1,
124
+ page_size: int = 50,
125
+ ):
126
+ if kind is not None and kind not in ("add", "search"):
127
+ raise HTTPException(status_code=422, detail="invalid kind")
128
+ # events live in the sidecar, not a namespace .db — no _ns_db guard,
129
+ # but reserved namespaces are still rejected.
130
+ if is_reserved_namespace(namespace):
131
+ raise HTTPException(status_code=404, detail="unknown namespace")
132
+ event_log = request.app.state.event_log
133
+ return event_log.list_events(
134
+ namespace, kind=kind, page=page, page_size=page_size
135
+ )
136
+
137
+ @router.post(
138
+ "/{namespace}/test-search", dependencies=[Depends(require_auth)]
139
+ )
140
+ async def test_search(
141
+ request: Request, namespace: str, body: TestSearchBody
142
+ ):
143
+ # Guard existence (and reserved) exactly like _ns_db: a search against a
144
+ # nonexistent namespace must 404, NOT create the .db file as a side
145
+ # effect of the engine opening it.
146
+ _ns_db(request, namespace)
147
+ gateway = request.app.state.gateway
148
+ result = await gateway.search(
149
+ namespace, body.query, k=body.k, latest_only=True, origin="ui"
150
+ )
151
+ return {"hits": result.hits, "duration_ms": result.duration_ms}
152
+
153
+ return router
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: lean-memory-console
3
+ Version: 0.2.0
4
+ Summary: Agent-first read-only verification console for lean-memory
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: lean-memory
8
+ Requires-Dist: mcp>=1.0
9
+ Requires-Dist: uvicorn>=0.30
10
+ Provides-Extra: dev
11
+ Requires-Dist: anyio>=4; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Requires-Dist: pyyaml; extra == 'dev'
@@ -0,0 +1,20 @@
1
+ lean_memory_console/__init__.py,sha256=Jg18i1Ur9fTFAdcvJgPwgDeXWxn8Wuywbeh1UmQ6qwI,97
2
+ lean_memory_console/app.py,sha256=_PQPt0FcjleWmlT-m6ncLkIOpvupF8Hjs51LJ_5tFUc,6033
3
+ lean_memory_console/cli.py,sha256=WBykbiX7aM2Mw1aXr4BFT4kUPfyzgl8UbQzPfOr1LA0,4864
4
+ lean_memory_console/config.py,sha256=F1VlsSVpMV5HUPBw6Mq_tPsZYGRLIHNGZbK0qqyVfs0,4269
5
+ lean_memory_console/engine.py,sha256=NHdDUc7h29ubijIK7oQ9WwDO3NKBX91MXTlsNLjLxTo,15001
6
+ lean_memory_console/events.py,sha256=8huPjL4SZ2F1KqiEEat1-RV6U8TDzjFh5SDT9E_Hbjk,5027
7
+ lean_memory_console/inspect_sql.py,sha256=TFnln9CnCxKIz4jCOQzhVGFiC8ikonXZxPXHwpSVqUk,11446
8
+ lean_memory_console/mcp_tools.py,sha256=ecdr1SJqkfJJ2E4iIc-nC1ly5MWiv_n-mm7EH28762w,6985
9
+ lean_memory_console/observe_mcp.py,sha256=mR5RfxBSParlfrtwNQjCg_afWLsOqK2ZA5k-StDynWs,2380
10
+ lean_memory_console/deploy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ lean_memory_console/routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ lean_memory_console/routes/data.py,sha256=lzneIzjohQpG5kOET0Ex108w83Lo4Xkt_SuICN3rt2U,1620
13
+ lean_memory_console/routes/mcp.py,sha256=E_w98Ttp9umZoXX1bBrZuSu8MG0x8t6IXH23Cp_E9Ys,7686
14
+ lean_memory_console/routes/review.py,sha256=V0byVoRhXCSetWkYGpwjsSdH2BOzi2V-oyOOh9-Lgnc,3828
15
+ lean_memory_console/routes/views.py,sha256=AtqiBctCJoaXHRiVTChx0RIF08GEHjF7kB5QqsTZ0Ec,5664
16
+ lean_memory_console/deploy/docker-compose.yml,sha256=DnRlp3KHRqXq2OZKTWptU7lOk6GiPPMzdnuEciT7-cw,1393
17
+ lean_memory_console-0.2.0.dist-info/METADATA,sha256=cbn0cdmj28n0_K0Hi5PqcOwy1IrAM1wDd9E5zq_GtEY,447
18
+ lean_memory_console-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
+ lean_memory_console-0.2.0.dist-info/entry_points.txt,sha256=Cs0A0akgNoDIfi7y5nZTd94zkSCXgNWhqyl5YF4H2Ow,69
20
+ lean_memory_console-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lean-memory-console = lean_memory_console.cli:main