From 74268351bf21c1569acf46c64a99c0053fda2393 Mon Sep 17 00:00:00 2001 From: sdev Date: Fri, 8 May 2026 13:24:36 +0530 Subject: [PATCH 01/13] feat(server): bearer-token ASGI auth wrapper for /mcp mount --- src/engram/server/auth.py | 65 ++++++++++++++++++++++++++++ tests/unit/test_auth.py | 91 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/engram/server/auth.py create mode 100644 tests/unit/test_auth.py diff --git a/src/engram/server/auth.py b/src/engram/server/auth.py new file mode 100644 index 0000000..86bd305 --- /dev/null +++ b/src/engram/server/auth.py @@ -0,0 +1,65 @@ +"""Bearer-token ASGI auth wrapper. + +Wraps an ASGI app so HTTP requests must carry `Authorization: Bearer `. +Non-HTTP scopes (lifespan, websocket) pass through unchanged. + +Used to gate the `/mcp` mount in `build_http_app`. The `/v1/memory/*` REST +routes are intentionally NOT gated — see the v0.2 design spec. +""" + +from __future__ import annotations + +import json +from secrets import compare_digest +from typing import Any, Awaitable, Callable + +ASGIApp = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]] + + +_UNAUTH_BODY = json.dumps({"error": "invalid bearer token"}).encode("utf-8") + + +async def _send_401(send: Callable[[Any], Awaitable[None]]) -> None: + await send({ + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(_UNAUTH_BODY)).encode("ascii")), + (b"www-authenticate", b'Bearer realm="engram"'), + ], + }) + await send({"type": "http.response.body", "body": _UNAUTH_BODY}) + + +def auth_asgi_wrapper(app: ASGIApp, expected_token: str | None) -> ASGIApp: + """Return `app` wrapped with bearer-token auth. + + If `expected_token` is None, returns `app` unchanged (used for --no-auth mode). + Otherwise, HTTP requests without `Authorization: Bearer ` matching + `expected_token` (constant-time compared) get a 401 JSON response. + Non-HTTP scopes pass through. + """ + if expected_token is None: + return app + + async def wrapped(scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]) -> None: + if scope.get("type") != "http": + await app(scope, receive, send) + return + + header_value = b"" + for name, value in scope.get("headers", []): + if name == b"authorization": + header_value = value + break + + if header_value.startswith(b"Bearer "): + token = header_value[len(b"Bearer "):].decode("utf-8", errors="replace") + if compare_digest(token, expected_token): + await app(scope, receive, send) + return + + await _send_401(send) + + return wrapped diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..456027c --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,91 @@ +"""Tests for the bearer-token ASGI auth wrapper.""" + +from __future__ import annotations + +import pytest +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from engram.server.auth import auth_asgi_wrapper + + +def _ok_app() -> Starlette: + async def hello(request): # noqa: ANN001, ARG001 + return PlainTextResponse("ok") + + return Starlette(routes=[Route("/", hello)]) + + +def test_no_token_disables_auth(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token=None) + client = TestClient(wrapped) + resp = client.get("/") + assert resp.status_code == 200 + assert resp.text == "ok" + + +def test_correct_token_passes(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/", headers={"Authorization": "Bearer secret123"}) + assert resp.status_code == 200 + + +def test_missing_header_rejected(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/") + assert resp.status_code == 401 + assert resp.json() == {"error": "invalid bearer token"} + + +def test_wrong_scheme_rejected(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/", headers={"Authorization": "Basic secret123"}) + assert resp.status_code == 401 + + +def test_wrong_token_rejected(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/", headers={"Authorization": "Bearer not-the-token"}) + assert resp.status_code == 401 + + +def test_constant_time_compare_used(monkeypatch): + calls = [] + import secrets as _secrets + + real_compare = _secrets.compare_digest + + def spy(a, b): + calls.append((a, b)) + return real_compare(a, b) + + monkeypatch.setattr("engram.server.auth.compare_digest", spy) + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + client.get("/", headers={"Authorization": "Bearer secret123"}) + assert calls, "compare_digest should have been called for token comparison" + + +def test_websocket_scope_passes_through(): + # Non-HTTP scopes (websocket, lifespan) should pass through unchanged so the + # underlying app handles them. + seen = [] + + async def inner(scope, receive, send): + seen.append(scope["type"]) + + wrapped = auth_asgi_wrapper(inner, expected_token="secret123") + + import anyio + + async def drive(): + await wrapped({"type": "lifespan"}, lambda: None, lambda m: None) + + anyio.run(drive) + assert seen == ["lifespan"] From 8307c879aaa29b6b7852180ad92924b3116b7dbb Mon Sep 17 00:00:00 2001 From: sdev Date: Fri, 8 May 2026 19:01:32 +0530 Subject: [PATCH 02/13] feat(server): Streamable HTTP transport adapter (transport.py) --- src/engram/server/transport.py | 44 ++++++++++++++++++++++++++++++++ tests/unit/test_transport.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/engram/server/transport.py create mode 100644 tests/unit/test_transport.py diff --git a/src/engram/server/transport.py b/src/engram/server/transport.py new file mode 100644 index 0000000..5591a45 --- /dev/null +++ b/src/engram/server/transport.py @@ -0,0 +1,44 @@ +"""Streamable HTTP transport adapter for Engram's MCP server. + +This is the only module that imports from `mcp.server.streamable_http_manager`. +If the MCP SDK changes its transport API, only this file changes. + +Usage: + manager, handler = build_streamable_http_mount(mcp_server) + # `manager.run()` must be entered as a lifespan context in the host app. + # `handler` is an ASGI handler (callable scope/receive/send) suitable for + # mounting. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from mcp.server import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + +ASGIHandler = Callable[ + [dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], + Awaitable[None], +] + + +def build_streamable_http_mount( + mcp_server: Server, +) -> tuple[StreamableHTTPSessionManager, ASGIHandler]: + """Build a Streamable HTTP session manager and ASGI handler for `mcp_server`. + + Returns `(manager, handler)`. The caller is responsible for: + 1. Entering `manager.run()` as a lifespan context manager on the host app. + 2. Mounting `handler` (or an auth-wrapped version of it) at the desired path. + + The manager is created with `stateless=True` because Engram tools have no + per-session state — each call hits the same Engram instance. This means + multiple replicas can sit behind a load balancer without sticky sessions. + """ + manager = StreamableHTTPSessionManager( + app=mcp_server, + stateless=True, + json_response=False, + ) + return manager, manager.handle_request diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py new file mode 100644 index 0000000..b2b5c3e --- /dev/null +++ b/tests/unit/test_transport.py @@ -0,0 +1,46 @@ +"""Unit tests for the Streamable HTTP transport adapter.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio + +from engram.engram import Engram +from engram.server.mcp import build_mcp_server +from engram.server.transport import build_streamable_http_mount + + +@pytest_asyncio.fixture +async def engram_instance(tmp_path, monkeypatch): + """Create an Engram instance with mock LLM for testing.""" + monkeypatch.setenv("ENGRAM_LLM_PROVIDER", "mock") + engram = await Engram.open(path=str(tmp_path / "engram.db")) + try: + yield engram + finally: + await engram.close() + + +@pytest.mark.asyncio +async def test_returns_manager_and_handler(engram_instance): + """Test that build_streamable_http_mount returns manager and handler.""" + mcp_server = build_mcp_server(engram_instance, name="engram-test") + manager, handler = build_streamable_http_mount(mcp_server) + + # The handler is the manager's handle_request method. + assert handler == manager.handle_request + + # The manager should be configured stateless per the v0.2 spec. + assert manager.stateless is True + + +@pytest.mark.asyncio +async def test_idempotent_singleton_warning(engram_instance): + """Per StreamableHTTPSessionManager docs, only one instance per app — but our + builder is called once at app-build time, so this just verifies the builder + returns a fresh instance on each call (callers must not call it twice).""" + mcp_server = build_mcp_server(engram_instance, name="engram-test") + + m1, _ = build_streamable_http_mount(mcp_server) + m2, _ = build_streamable_http_mount(mcp_server) + assert m1 is not m2 From a4076dabce7b0d1ef6265691c7a8b45d01dd359c Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 12:14:22 +0530 Subject: [PATCH 03/13] feat(server): mount /mcp Streamable HTTP on FastAPI app with optional auth --- src/engram/server/http.py | 48 ++++++++++++++++++++++++++-- tests/integration/test_http_mcp.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_http_mcp.py diff --git a/src/engram/server/http.py b/src/engram/server/http.py index 9ef89a5..e982cd8 100644 --- a/src/engram/server/http.py +++ b/src/engram/server/http.py @@ -6,16 +6,21 @@ from __future__ import annotations +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import datetime from typing import Any from uuid import UUID from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field +from starlette.routing import Route from engram.engram import Engram from engram.models import ChatMessage, MemoryTier, Polarity from engram.scope import Scope +from engram.server.auth import auth_asgi_wrapper +from engram.server.transport import build_streamable_http_mount class _RecordRequest(BaseModel): @@ -99,9 +104,46 @@ class _ExtractResponse(BaseModel): facts: list[_ExtractedFactSummary] -def build_http_app(engram: Engram) -> FastAPI: - """Build a FastAPI app bound to a single Engram instance.""" - app = FastAPI(title="Engram", version="0.1.0a0") +def build_http_app( + engram: Engram, + *, + mcp_server: Any | None = None, + auth_token: str | None = None, +) -> FastAPI: + """Build a FastAPI app bound to a single Engram instance. + + Args: + engram: The Engram instance to serve. + mcp_server: Optional MCP `Server` (e.g. from `build_mcp_server`). If + provided, a Streamable HTTP MCP endpoint is mounted at `/mcp`. + auth_token: Bearer token required on `/mcp`. If None, `/mcp` is unauthed + (only safe on loopback). The legacy `/v1/memory/*` routes are NEVER + auth-gated regardless of this argument — see v0.2 spec. + """ + if mcp_server is not None: + manager, handler = build_streamable_http_mount(mcp_server) + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with manager.run(): + yield + + app = FastAPI(title="Engram", version="0.2.0", lifespan=lifespan) + + wrapped_handler = auth_asgi_wrapper(handler, expected_token=auth_token) + + # Route (not Mount) so that POST /mcp hits the handler without a + # 307 redirect. Starlette's Mount matches /mcp/ and below, but + # redirects bare /mcp; Route matches the exact path. + # The handler is wrapped in a class so Starlette treats it as an ASGI + # app (methods=None = all HTTP methods) rather than a GET-only function. + class _MCPHandler: + async def __call__(self, scope_: Any, receive_: Any, send_: Any) -> None: + await wrapped_handler(scope_, receive_, send_) + + app.router.routes.append(Route("/mcp", endpoint=_MCPHandler())) + else: + app = FastAPI(title="Engram", version="0.2.0") @app.get("/healthz") async def healthz() -> dict[str, str]: diff --git a/tests/integration/test_http_mcp.py b/tests/integration/test_http_mcp.py new file mode 100644 index 0000000..b262b05 --- /dev/null +++ b/tests/integration/test_http_mcp.py @@ -0,0 +1,51 @@ +"""Integration tests for /mcp Streamable HTTP endpoint. + +Drives the MCP handshake via httpx ASGITransport — no real socket binding. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from engram.engram import Engram +from engram.server.http import build_http_app +from engram.server.mcp import build_mcp_server + + +@pytest_asyncio.fixture +async def app(tmp_path, monkeypatch): + monkeypatch.setenv("ENGRAM_LLM_PROVIDER", "mock") + engram = await Engram.open(path=str(tmp_path / "engram.db")) + try: + mcp_server = build_mcp_server(engram, name="engram-test") + yield build_http_app(engram, mcp_server=mcp_server, auth_token="secret123") + finally: + await engram.close() + + +@pytest.mark.asyncio +async def test_mcp_route_requires_auth(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + # No auth header → 401 + resp = await client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + assert resp.status_code == 401 + + # Wrong token → 401 + resp = await client.post( + "/mcp", + headers={"Authorization": "Bearer wrong"}, + json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_healthz_unauthed(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} From ab3ffd564f39abbccf4c9fa8b873a40085a8a645 Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 12:59:21 +0530 Subject: [PATCH 04/13] test(integration): regression guard for /v1 unauthed alongside /mcp gated --- tests/integration/test_v1_rest_unaffected.py | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/integration/test_v1_rest_unaffected.py diff --git a/tests/integration/test_v1_rest_unaffected.py b/tests/integration/test_v1_rest_unaffected.py new file mode 100644 index 0000000..6ae1e49 --- /dev/null +++ b/tests/integration/test_v1_rest_unaffected.py @@ -0,0 +1,51 @@ +"""Regression guard: /v1/memory/* must remain unauthed even when /mcp has auth. + +This is the asymmetric-auth design choice from the v0.2 spec. If someone later +adds auth to /v1, they must update this test deliberately. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from engram.engram import Engram +from engram.server.http import build_http_app +from engram.server.mcp import build_mcp_server + + +@pytest_asyncio.fixture +async def app(tmp_path, monkeypatch): + monkeypatch.setenv("ENGRAM_LLM_PROVIDER", "mock") + engram = await Engram.open(path=str(tmp_path / "engram.db")) + try: + mcp_server = build_mcp_server(engram, name="engram-test") + yield build_http_app(engram, mcp_server=mcp_server, auth_token="secret123") + finally: + await engram.close() + + +@pytest.mark.asyncio +async def test_v1_record_works_without_auth(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/v1/memory/record", + json={"text": "test fact", "user_id": "alice"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["text"] == "test fact" + assert "fact_id" in body + + +@pytest.mark.asyncio +async def test_v1_recall_get_works_without_auth(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get( + "/v1/memory/recall", params={"query": "anything", "user_id": "alice"} + ) + assert resp.status_code == 200 + assert "hits" in resp.json() From caf62675a010948050aa327f980f43a951351e72 Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 17:32:06 +0530 Subject: [PATCH 05/13] test(integration): full MCP handshake + tools/call round-trip over HTTP --- tests/integration/test_http_mcp.py | 232 ++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_http_mcp.py b/tests/integration/test_http_mcp.py index b262b05..2abef95 100644 --- a/tests/integration/test_http_mcp.py +++ b/tests/integration/test_http_mcp.py @@ -5,28 +5,75 @@ from __future__ import annotations +import asyncio +import json +from collections.abc import AsyncGenerator, MutableMapping +from typing import Any + import pytest import pytest_asyncio -from httpx import ASGITransport, AsyncClient +from anyio import create_memory_object_stream +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient, Response from engram.engram import Engram from engram.server.http import build_http_app from engram.server.mcp import build_mcp_server +async def _start_lifespan(asgi_app: FastAPI) -> asyncio.Task[None]: + """Send an ASGI lifespan.startup event and wait for startup.complete. + + Returns the background asyncio Task running the lifespan coroutine so the + caller can cancel it during teardown. Using a raw asyncio.Task (rather than + anyio.create_task_group) avoids the "cancel scope in a different task" + restriction that anyio enforces during pytest fixture teardown. + """ + send_chan, recv_chan = create_memory_object_stream[dict[str, Any]](2) + startup_complete: asyncio.Event = asyncio.Event() + scope: dict[str, Any] = {"type": "lifespan", "asgi": {"version": "3.0"}} + + async def _receive() -> dict[str, Any]: + return await recv_chan.receive() + + async def _send(message: MutableMapping[str, Any]) -> None: + if message["type"] == "lifespan.startup.complete": + startup_complete.set() + + task: asyncio.Task[None] = asyncio.ensure_future(asgi_app(scope, _receive, _send)) + await send_chan.send({"type": "lifespan.startup"}) + await startup_complete.wait() + return task + + @pytest_asyncio.fixture -async def app(tmp_path, monkeypatch): +async def app(tmp_path: Any, monkeypatch: Any) -> AsyncGenerator[FastAPI, None]: + """Build a FastAPI app with MCP mounted and the ASGI lifespan started. + + ASGITransport does not trigger the ASGI lifespan, so we start it manually + by sending lifespan.startup and waiting for lifespan.startup.complete before + yielding to the test. The background task is cancelled on teardown. + """ monkeypatch.setenv("ENGRAM_LLM_PROVIDER", "mock") engram = await Engram.open(path=str(tmp_path / "engram.db")) + lifespan_task: asyncio.Task[None] | None = None try: mcp_server = build_mcp_server(engram, name="engram-test") - yield build_http_app(engram, mcp_server=mcp_server, auth_token="secret123") + asgi_app = build_http_app(engram, mcp_server=mcp_server, auth_token="secret123") + lifespan_task = await _start_lifespan(asgi_app) + yield asgi_app finally: + if lifespan_task is not None: + lifespan_task.cancel() + try: + await lifespan_task + except (asyncio.CancelledError, Exception): + pass await engram.close() @pytest.mark.asyncio -async def test_mcp_route_requires_auth(app): +async def test_mcp_route_requires_auth(app: FastAPI) -> None: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: # No auth header → 401 @@ -43,9 +90,184 @@ async def test_mcp_route_requires_auth(app): @pytest.mark.asyncio -async def test_healthz_unauthed(app): +async def test_healthz_unauthed(app: FastAPI) -> None: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get("/healthz") assert resp.status_code == 200 assert resp.json() == {"status": "ok"} + + +async def _post_mcp( + client: AsyncClient, body: dict[str, Any], session_id: str | None = None +) -> Response: + headers: dict[str, str] = { + "Authorization": "Bearer secret123", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + if session_id: + headers["mcp-session-id"] = session_id + return await client.post("/mcp", headers=headers, json=body) + + +def _parse_body(resp: Response) -> dict[str, Any]: + """The streamable HTTP endpoint may return either application/json or + text/event-stream. Decode either into a JSON dict.""" + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + result: dict[str, Any] = resp.json() + return result + if "text/event-stream" in content_type: + # Find the first `data: { ... }` line. + for line in resp.text.splitlines(): + if line.startswith("data: "): + parsed: dict[str, Any] = json.loads(line[len("data: "):]) + return parsed + raise AssertionError(f"no data: line in SSE body: {resp.text!r}") + raise AssertionError(f"unexpected content-type: {content_type!r}") + + +@pytest.mark.asyncio +async def test_full_handshake_and_tools_list(app: FastAPI) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + init_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + resp = await _post_mcp(client, init_body) + assert resp.status_code == 200 + # stateless=True mode: session ID may be absent — capture if present + session_id = resp.headers.get("mcp-session-id") + init_result = _parse_body(resp) + assert init_result["result"]["serverInfo"]["name"] == "engram-test" + + # initialized notification + await _post_mcp( + client, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + session_id=session_id, + ) + + # tools/list + resp = await _post_mcp( + client, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + session_id=session_id, + ) + assert resp.status_code == 200 + tools = _parse_body(resp)["result"]["tools"] + names = sorted(t["name"] for t in tools) + assert names == ["memory_context", "memory_recall", "memory_record"] + + +@pytest.mark.asyncio +async def test_memory_record_then_recall_round_trip(app: FastAPI) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + init_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + resp = await _post_mcp(client, init_body) + # stateless=True mode: session ID may be absent + session_id = resp.headers.get("mcp-session-id") + await _post_mcp( + client, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + session_id=session_id, + ) + + # memory_record + resp = await _post_mcp( + client, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "memory_record", + "arguments": {"text": "Alice's dog is named Pepper", "user_id": "alice"}, + }, + }, + session_id=session_id, + ) + assert resp.status_code == 200 + body = _parse_body(resp) + record_text = body["result"]["content"][0]["text"] + assert record_text.startswith("recorded fact ") + + # memory_recall — should retrieve the fact we just stored + resp = await _post_mcp( + client, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "memory_recall", + "arguments": {"query": "what is Alice's dog called", "user_id": "alice"}, + }, + }, + session_id=session_id, + ) + assert resp.status_code == 200 + body = _parse_body(resp) + recall_text = body["result"]["content"][0]["text"] + # Recall returns "- [score=X.XXX] " lines OR "(no matches)" + assert "Pepper" in recall_text or "(no matches)" in recall_text + + +@pytest.mark.asyncio +async def test_unknown_tool_returns_jsonrpc_error(app: FastAPI) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + init_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + resp = await _post_mcp(client, init_body) + # stateless=True mode: session ID may be absent + session_id = resp.headers.get("mcp-session-id") + await _post_mcp( + client, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + session_id=session_id, + ) + + resp = await _post_mcp( + client, + { + "jsonrpc": "2.0", + "id": 99, + "method": "tools/call", + "params": {"name": "no_such_tool", "arguments": {}}, + }, + session_id=session_id, + ) + # MCP SDK surfaces tool handler exceptions as a successful JSON-RPC response + # with result.isError=True and the error message in result.content[0].text. + # The HTTP status is still 200 — errors are in-band per MCP spec. + assert resp.status_code == 200 + body = _parse_body(resp) + assert body["result"]["isError"] is True + assert "no_such_tool" in body["result"]["content"][0]["text"] From f5bb0292da65d5b33155a5e9416f09ad07c0537d Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 18:44:43 +0530 Subject: [PATCH 06/13] test(integration): tighten lifespan teardown except + fixture param types --- tests/integration/test_http_mcp.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_http_mcp.py b/tests/integration/test_http_mcp.py index 2abef95..590cf68 100644 --- a/tests/integration/test_http_mcp.py +++ b/tests/integration/test_http_mcp.py @@ -8,6 +8,7 @@ import asyncio import json from collections.abc import AsyncGenerator, MutableMapping +from pathlib import Path from typing import Any import pytest @@ -47,7 +48,7 @@ async def _send(message: MutableMapping[str, Any]) -> None: @pytest_asyncio.fixture -async def app(tmp_path: Any, monkeypatch: Any) -> AsyncGenerator[FastAPI, None]: +async def app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[FastAPI, None]: """Build a FastAPI app with MCP mounted and the ASGI lifespan started. ASGITransport does not trigger the ASGI lifespan, so we start it manually @@ -67,7 +68,7 @@ async def app(tmp_path: Any, monkeypatch: Any) -> AsyncGenerator[FastAPI, None]: lifespan_task.cancel() try: await lifespan_task - except (asyncio.CancelledError, Exception): + except asyncio.CancelledError: pass await engram.close() From fb385e58e717debacf85846feb8494cc8c59a70b Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 18:46:29 +0530 Subject: [PATCH 07/13] chore: bump to 0.2.0, add typer dep + engram-server scripts entry --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f76d3e6..a0c10e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jamjet-engram" -version = "0.1.0" +version = "0.2.0" description = "Engram — durable memory layer for AI agents. Temporal knowledge graph, semantic search, and MCP-native tools." readme = "README.md" license = { text = "Apache-2.0" } @@ -32,6 +32,7 @@ dependencies = [ "python-dateutil>=2.9.0.post0", "openai>=2.33.0", "anthropic>=0.97.0", + "typer>=0.12", ] [project.optional-dependencies] @@ -61,6 +62,9 @@ Repository = "https://github.com/jamjet-labs/engram" Documentation = "https://docs.jamjet.dev/engram/v2/" Issues = "https://github.com/jamjet-labs/engram/issues" +[project.scripts] +engram-server = "engram.cli:app" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" From 0aa3481c97461c19f25ac2d390700621c9224666 Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 19:08:33 +0530 Subject: [PATCH 08/13] feat(cli): engram-server typer CLI with fail-closed auth validation --- src/engram/cli.py | 108 +++++++++++++++++++++++++++++++++++++++++ tests/unit/test_cli.py | 67 +++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/engram/cli.py create mode 100644 tests/unit/test_cli.py diff --git a/src/engram/cli.py b/src/engram/cli.py new file mode 100644 index 0000000..d1b369b --- /dev/null +++ b/src/engram/cli.py @@ -0,0 +1,108 @@ +"""`engram-server` CLI — typer-based. + +Resolves flags + env, validates startup conditions (fail-closed on missing +auth token, loopback-only for --no-auth), then hands off to uvicorn. + +Use `--dry-run` in tests to validate flags without actually binding a port. +""" + +from __future__ import annotations + +import asyncio +import os +from typing import Any + +import typer + +LOOPBACK_HOSTS = {"127.0.0.1", "::1"} +MIN_TOKEN_LEN = 32 + +app = typer.Typer(add_completion=False, help="Engram MCP HTTP server") + + +def _eprint(msg: str) -> None: + typer.echo(msg, err=True) + + +def _print_version() -> None: + from importlib.metadata import version as _pkg_version + + typer.echo(_pkg_version("jamjet-engram")) + raise typer.Exit(0) + + +@app.command() +def main( + transport: str = typer.Option("http", "--transport", help="Only `http` valid in v0.2."), + host: str = typer.Option("127.0.0.1", "--host", help="Bind host."), + port: int = typer.Option(8765, "--port", help="Bind port."), + auth_token_env: str = typer.Option( + "ENGRAM_AUTH_TOKEN", "--auth-token-env", help="Env var name for bearer token." + ), + no_auth: bool = typer.Option(False, "--no-auth", help="Disable auth (loopback only)."), + db_path: str | None = typer.Option(None, "--db-path", help="SQLite path."), + log_level: str = typer.Option("INFO", "--log-level"), + version: bool = typer.Option(False, "--version", help="Print version and exit."), + dry_run: bool = typer.Option(False, "--dry-run", help="Validate flags, do not bind."), +) -> None: + """Start the Engram MCP HTTP server.""" + if version: + _print_version() + + # Rule 1: transport must be http + if transport != "http": + _eprint(f"transport={transport} is reserved for a future release") + raise typer.Exit(2) + + # Rule 2: --no-auth requires loopback (exact IPs only; "localhost" rejected) + if no_auth and host not in LOOPBACK_HOSTS: + _eprint( + "--no-auth is only allowed with --host 127.0.0.1 or ::1 " + "(localhost is rejected because /etc/hosts can redirect it)" + ) + raise typer.Exit(2) + + # Rule 3: token required unless --no-auth (fail-closed) + auth_token: str | None = None + if not no_auth: + auth_token = os.environ.get(auth_token_env) + if not auth_token: + _eprint( + f"${auth_token_env} is unset; set it or pass --no-auth (loopback only).\n" + "Generate a token with: " + "python -c 'import secrets; print(secrets.token_urlsafe(32))'" + ) + raise typer.Exit(2) + + # Rule 4: short-token warning (not blocking) + if len(auth_token) < MIN_TOKEN_LEN: + _eprint( + f"warning: ${auth_token_env} is shorter than {MIN_TOKEN_LEN} chars; " + "this is weak. Suggested: python -c 'import secrets; " + "print(secrets.token_urlsafe(32))'" + ) + + if dry_run: + return + + # Late imports — keep --version, --help, and dry-run fast. + import uvicorn + + from engram.engram import Engram + from engram.server.http import build_http_app + from engram.server.mcp import build_mcp_server + + resolved_db = db_path or os.environ.get("ENGRAM_DB_PATH") or "engram.db" + + async def _setup() -> Any: + engram = await Engram.open(path=resolved_db) + mcp_server = build_mcp_server(engram, name="engram") + return build_http_app(engram, mcp_server=mcp_server, auth_token=auth_token) + + fast_app = asyncio.run(_setup()) + + uvicorn.run(fast_app, host=host, port=port, log_level=log_level.lower()) + + +if __name__ == "__main__": + app() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..0bcca06 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,67 @@ +"""CLI flag-validation tests. Does not exercise uvicorn/server lifecycle.""" + +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from engram.cli import app + +runner = CliRunner() + + +def test_version_flag(): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "0.2.0" in result.stdout + + +def test_transport_must_be_http(): + result = runner.invoke(app, ["--transport", "stdio"]) + assert result.exit_code == 2 + assert "reserved for a future release" in result.stderr or "reserved" in result.output + + +def test_no_auth_requires_loopback_127(monkeypatch): + monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) + result = runner.invoke(app, ["--no-auth", "--host", "0.0.0.0", "--dry-run"]) + assert result.exit_code == 2 + assert "loopback" in (result.stderr + result.output).lower() or "127.0.0.1" in (result.stderr + result.output) + + +def test_no_auth_rejects_localhost_string(monkeypatch): + monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) + result = runner.invoke(app, ["--no-auth", "--host", "localhost", "--dry-run"]) + assert result.exit_code == 2 + + +def test_no_auth_accepts_ipv4_loopback(monkeypatch): + monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) + result = runner.invoke(app, ["--no-auth", "--host", "127.0.0.1", "--dry-run"]) + assert result.exit_code == 0 + + +def test_no_auth_accepts_ipv6_loopback(monkeypatch): + monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) + result = runner.invoke(app, ["--no-auth", "--host", "::1", "--dry-run"]) + assert result.exit_code == 0 + + +def test_missing_token_rejects(monkeypatch): + monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) + result = runner.invoke(app, ["--dry-run"]) + assert result.exit_code == 2 + assert "ENGRAM_AUTH_TOKEN" in (result.stderr + result.output) + + +def test_token_present_starts(monkeypatch): + monkeypatch.setenv("ENGRAM_AUTH_TOKEN", "x" * 40) + result = runner.invoke(app, ["--dry-run"]) + assert result.exit_code == 0 + + +def test_short_token_warns_but_starts(monkeypatch): + monkeypatch.setenv("ENGRAM_AUTH_TOKEN", "short") + result = runner.invoke(app, ["--dry-run"]) + assert result.exit_code == 0 + assert "32" in (result.stderr + result.output) or "weak" in (result.stderr + result.output).lower() From 7c5059e57a4a1d7e3b7127e7a6d2fef1e92cb26a Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 19:21:53 +0530 Subject: [PATCH 09/13] feat(docker): Dockerfile + .dockerignore for engram-py-server image --- .dockerignore | 18 ++++++++++++++++++ Dockerfile | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6785227 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.venv +__pycache__ +*.pyc +*.pyo +*.egg-info +build +dist +docs +tests +benchmarks +training +*.db +*.sqlite +.pytest_cache +.mypy_cache +.ruff_cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c81e534 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1.7 + +FROM python:3.13-slim + +# System deps for hnswlib compilation and curl for healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user for runtime. +RUN useradd --create-home --uid 1000 engram + +WORKDIR /app + +# Copy minimal install artifacts. +COPY pyproject.toml README.md ./ +COPY src ./src + +# Install the package itself. +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir . + +# Persistent volume for the SQLite DB. +RUN mkdir -p /data && chown engram:engram /data +VOLUME ["/data"] +ENV ENGRAM_DB_PATH=/data/engram.db + +USER engram + +EXPOSE 8765 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -fsS http://localhost:8765/healthz || exit 1 + +# Default to HTTP transport binding all interfaces inside the container. +# The operator MUST provide ENGRAM_AUTH_TOKEN at run time. +CMD ["engram-server", "--transport", "http", "--host", "0.0.0.0", "--port", "8765"] From b744830efa5afaea733f5751e1392a55c4164aa0 Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 19:24:46 +0530 Subject: [PATCH 10/13] ci: build + push engram-py-server image to GHCR on release --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85a0c8..fe00e5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,3 +104,62 @@ jobs: files: dist/* # Mark pre-releases (e.g. v1.0.0-rc1) automatically. prerelease: ${{ contains(github.ref_name, '-') }} + + docker: + name: Build and push Docker image to GHCR + runs-on: ubuntu-24.04 + needs: publish-pypi + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + # tag format: v0.2.0 or v0.2.0-rc1 + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # latest tag only for non-prerelease versions + if [[ "$VERSION" == *-* ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (versioned + latest) + if: steps.version.outputs.is_prerelease == 'false' + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/jamjet-labs/engram-py-server:${{ steps.version.outputs.version }} + ghcr.io/jamjet-labs/engram-py-server:latest + platforms: linux/amd64,linux/arm64 + + - name: Build and push (versioned only, prerelease) + if: steps.version.outputs.is_prerelease == 'true' + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/jamjet-labs/engram-py-server:${{ steps.version.outputs.version }} + platforms: linux/amd64,linux/arm64 From 39375cbf495b36ffccaa3626df08ca2fa70aa90c Mon Sep 17 00:00:00 2001 From: sdev Date: Sat, 9 May 2026 19:30:03 +0530 Subject: [PATCH 11/13] docs: README hosted-MCP section + CHANGELOG entry for 0.2.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c28eea7..64252bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ All notable changes to Engram v2.0 (the Python rewrite) are documented here. For ### Fixed - `benchmarks/smoke_runner.py` and `benchmarks/longmemeval_v2.py`: replaced developer-machine-specific oracle-path fallbacks (`../jamjet-research/...`) with a generic `./longmemeval_oracle.json` default. The `LONGMEMEVAL_ORACLE` env var still takes priority; the new default is a sensible cwd-relative path that fails fast with a clear message if the file isn't there. +## [0.2.0] — 2026-05-09 + +### Added +- `engram-server` script entry point (typer-based CLI). Run `engram-server --help` for flags. +- **MCP Streamable HTTP transport** at `POST /mcp` (and `GET /mcp`) on the FastAPI app, mounted alongside the existing `/v1/memory/*` REST routes. +- Bearer-token auth for `/mcp` via `$ENGRAM_AUTH_TOKEN` (fail-closed; server refuses to start if unset). `--no-auth` opt-out is rejected unless `--host` is loopback (`127.0.0.1` or `::1`). +- `Dockerfile` shipping `ghcr.io/jamjet-labs/engram-py-server:0.2.0` — `python:3.13-slim` base, non-root user, healthcheck, `/data` volume. +- `src/engram/server/auth.py` (bearer-token ASGI middleware) and `src/engram/server/transport.py` (Streamable HTTP adapter) modules. +- CI: tagged release also builds and pushes `ghcr.io/jamjet-labs/engram-py-server:` (and `:latest` for non-prerelease) to GHCR. + +### Changed +- `build_http_app(engram)` now accepts optional `mcp_server` and `auth_token` keyword arguments. Existing single-arg callers keep working. +- Bumped version in `pyproject.toml` to `0.2.0`. Added `typer>=0.12` to runtime dependencies. + +### Deferred +- Stdio transport in the Python binary (CLI flag `--transport=stdio` reserved; rejects with a clear message in v0.2). +- OAuth2 dynamic client registration for `/mcp`. +- `/v1/memory/*` auth gating. +- Replacing the Rust `ghcr.io/jamjet-labs/engram-server` image. Stdio Claude Desktop users keep using v0.5.0. +- Tool surface expansion to match Rust's 11 tools (Engram v2 tool-port roadmap, separate workstream). + ## [0.1.0] - 2026-05-06 ### Added diff --git a/README.md b/README.md index cc3d2bb..026427d 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,55 @@ The benchmark history (visible in `CHANGELOG.md`) documents two ablation program --- +## Hosted MCP server + +`jamjet-engram` 0.2.0+ ships an `engram-server` binary that exposes Engram's MCP tools over **Streamable HTTP**. Use it to host a remote Engram for JamJet Cloud agents, ChatGPT custom connectors, or any HTTP-aware MCP client. + +### Quickstart + +```bash +pip install jamjet-engram +export ENGRAM_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))") +engram-server --transport http --port 8765 +``` + +Or via Docker: + +```bash +docker run --rm \ + -e ENGRAM_AUTH_TOKEN="$ENGRAM_AUTH_TOKEN" \ + -v engram-data:/data \ + -p 8765:8765 \ + ghcr.io/jamjet-labs/engram-py-server:0.2.0 +``` + +### Connecting a client + +```bash +curl -i -X POST http://localhost:8765/mcp \ + -H "Authorization: Bearer $ENGRAM_AUTH_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2025-03-26","capabilities":{}, + "clientInfo":{"name":"curl","version":"1"}}}' +``` + +The server returns `MCP-Session-Id` in the response headers; subsequent requests echo it. + +### Auth model (v0.2) + +- `/mcp` requires `Authorization: Bearer ` matching `$ENGRAM_AUTH_TOKEN`. +- The server **refuses to start** if `$ENGRAM_AUTH_TOKEN` is unset (fail-closed). The only opt-out is `--no-auth`, which is rejected unless `--host` is `127.0.0.1` or `::1`. +- `/v1/memory/*` REST routes remain unauthed (matches the Rust `engram-server` REST behavior; deploy them behind a reverse proxy if you need auth). +- OAuth2 dynamic client registration (the Claude Desktop remote-server flavor) is on the roadmap. + +### Stdio transport + +For Claude Desktop and other stdio MCP clients, keep using the Rust `ghcr.io/jamjet-labs/engram-server:0.5.0` image — that path is unaffected by this release. + +--- + ## Contributing We genuinely welcome contributions. The repo is set up so a new contributor can ship a meaningful change in a couple of hours. From 60427c34734944ac53fea10d826133f555afb95c Mon Sep 17 00:00:00 2001 From: sdev Date: Sun, 10 May 2026 08:26:55 +0530 Subject: [PATCH 12/13] chore: ruff E501 line-length fixes across new MCP HTTP transport modules --- src/engram/server/auth.py | 34 ++++++++++++++++++------------ src/engram/server/transport.py | 3 ++- tests/integration/test_http_mcp.py | 2 +- tests/unit/test_auth.py | 3 +-- tests/unit/test_cli.py | 7 +++--- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/engram/server/auth.py b/src/engram/server/auth.py index 86bd305..c7448e3 100644 --- a/src/engram/server/auth.py +++ b/src/engram/server/auth.py @@ -10,25 +10,31 @@ from __future__ import annotations import json +from collections.abc import Awaitable, Callable from secrets import compare_digest -from typing import Any, Awaitable, Callable +from typing import Any -ASGIApp = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]] +# Type aliases to stay within line length limits +ReceiveFn = Callable[[], Awaitable[Any]] +SendFn = Callable[[Any], Awaitable[None]] +ASGIApp = Callable[[dict[str, Any], ReceiveFn, SendFn], Awaitable[None]] _UNAUTH_BODY = json.dumps({"error": "invalid bearer token"}).encode("utf-8") -async def _send_401(send: Callable[[Any], Awaitable[None]]) -> None: - await send({ - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(_UNAUTH_BODY)).encode("ascii")), - (b"www-authenticate", b'Bearer realm="engram"'), - ], - }) +async def _send_401(send: SendFn) -> None: + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(_UNAUTH_BODY)).encode("ascii")), + (b"www-authenticate", b'Bearer realm="engram"'), + ], + } + ) await send({"type": "http.response.body", "body": _UNAUTH_BODY}) @@ -43,7 +49,7 @@ def auth_asgi_wrapper(app: ASGIApp, expected_token: str | None) -> ASGIApp: if expected_token is None: return app - async def wrapped(scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]) -> None: + async def wrapped(scope: dict[str, Any], receive: ReceiveFn, send: SendFn) -> None: if scope.get("type") != "http": await app(scope, receive, send) return @@ -55,7 +61,7 @@ async def wrapped(scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], break if header_value.startswith(b"Bearer "): - token = header_value[len(b"Bearer "):].decode("utf-8", errors="replace") + token = header_value[len(b"Bearer ") :].decode("utf-8", errors="replace") if compare_digest(token, expected_token): await app(scope, receive, send) return diff --git a/src/engram/server/transport.py b/src/engram/server/transport.py index 5591a45..bd22559 100644 --- a/src/engram/server/transport.py +++ b/src/engram/server/transport.py @@ -12,7 +12,8 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable +from collections.abc import Awaitable, Callable +from typing import Any from mcp.server import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager diff --git a/tests/integration/test_http_mcp.py b/tests/integration/test_http_mcp.py index 590cf68..c4ed938 100644 --- a/tests/integration/test_http_mcp.py +++ b/tests/integration/test_http_mcp.py @@ -123,7 +123,7 @@ def _parse_body(resp: Response) -> dict[str, Any]: # Find the first `data: { ... }` line. for line in resp.text.splitlines(): if line.startswith("data: "): - parsed: dict[str, Any] = json.loads(line[len("data: "):]) + parsed: dict[str, Any] = json.loads(line[len("data: ") :]) return parsed raise AssertionError(f"no data: line in SSE body: {resp.text!r}") raise AssertionError(f"unexpected content-type: {content_type!r}") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 456027c..7df4872 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route @@ -12,7 +11,7 @@ def _ok_app() -> Starlette: - async def hello(request): # noqa: ANN001, ARG001 + async def hello(request): return PlainTextResponse("ok") return Starlette(routes=[Route("/", hello)]) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 0bcca06..bfb0f42 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from typer.testing import CliRunner from engram.cli import app @@ -26,7 +25,8 @@ def test_no_auth_requires_loopback_127(monkeypatch): monkeypatch.delenv("ENGRAM_AUTH_TOKEN", raising=False) result = runner.invoke(app, ["--no-auth", "--host", "0.0.0.0", "--dry-run"]) assert result.exit_code == 2 - assert "loopback" in (result.stderr + result.output).lower() or "127.0.0.1" in (result.stderr + result.output) + output = result.stderr + result.output + assert "loopback" in output.lower() or "127.0.0.1" in output def test_no_auth_rejects_localhost_string(monkeypatch): @@ -64,4 +64,5 @@ def test_short_token_warns_but_starts(monkeypatch): monkeypatch.setenv("ENGRAM_AUTH_TOKEN", "short") result = runner.invoke(app, ["--dry-run"]) assert result.exit_code == 0 - assert "32" in (result.stderr + result.output) or "weak" in (result.stderr + result.output).lower() + output = result.stderr + result.output + assert "32" in output or "weak" in output.lower() From 984ba387a052c230a00c7113aa236063ad271b31 Mon Sep 17 00:00:00 2001 From: sdev Date: Sun, 10 May 2026 08:44:02 +0530 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20event-loop=20binding,=20RFC=209110=20scheme=20casin?= =?UTF-8?q?g,=20changelog=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/engram/cli.py: extract serve_engram() and drive uvicorn via asyncio.run so Engram.open() (which holds a loop-bound aiosqlite connection) lives in the same event loop uvicorn serves on. Previously asyncio.run(_setup()) closed the loop before uvicorn.run() opened a new one — first MCP tool call would throw "Event loop is closed". Also calls engram.close() on shutdown (was leaked). - src/engram/server/auth.py: case-insensitive Bearer scheme match (RFC 9110 §11). Some clients (Go oauth2, lowercase curl) emit "bearer" — was 401. - tests/unit/test_auth.py: add positive tests for "bearer" and "BEARER". - CHANGELOG.md: add [0.2.0] release link target; update [unreleased] to compare from v0.2.0. Verified end-to-end via Docker smoke test: memory_record over /mcp now succeeds (this is the path that would have hit the loop bug). --- CHANGELOG.md | 3 ++- src/engram/cli.py | 47 ++++++++++++++++++++++++++++++--------- src/engram/server/auth.py | 7 ++++-- tests/unit/test_auth.py | 16 +++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64252bb..7334c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,5 +140,6 @@ All notable changes to Engram v2.0 (the Python rewrite) are documented here. For ### Tests ~280 tests (units + integration). mypy `--strict` clean. ruff + ruff format clean. CI green on Python 3.12 + 3.13. -[unreleased]: https://github.com/jamjet-labs/engram/compare/v0.1.0...HEAD +[unreleased]: https://github.com/jamjet-labs/engram/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/jamjet-labs/engram/releases/tag/v0.2.0 [0.1.0]: https://github.com/jamjet-labs/engram/releases/tag/v0.1.0 diff --git a/src/engram/cli.py b/src/engram/cli.py index d1b369b..b3fdd91 100644 --- a/src/engram/cli.py +++ b/src/engram/cli.py @@ -10,7 +10,6 @@ import asyncio import os -from typing import Any import typer @@ -85,6 +84,35 @@ def main( if dry_run: return + resolved_db = db_path or os.environ.get("ENGRAM_DB_PATH") or "engram.db" + + asyncio.run( + serve_engram( + host=host, + port=port, + db_path=resolved_db, + auth_token=auth_token, + log_level=log_level, + ) + ) + + +async def serve_engram( + *, + host: str, + port: int, + db_path: str, + auth_token: str | None, + log_level: str, +) -> None: + """Open Engram and serve the FastAPI app via uvicorn — all on one event loop. + + Engram.open() creates loop-bound resources (an aiosqlite Connection lives on + the loop that opened it). If we used asyncio.run(open_engram) followed by + uvicorn.run(...), the second call would create a different event loop and + every MCP tool call would explode with "Event loop is closed". Keeping the + full lifecycle inside one asyncio.run(serve_engram(...)) avoids that. + """ # Late imports — keep --version, --help, and dry-run fast. import uvicorn @@ -92,16 +120,15 @@ def main( from engram.server.http import build_http_app from engram.server.mcp import build_mcp_server - resolved_db = db_path or os.environ.get("ENGRAM_DB_PATH") or "engram.db" - - async def _setup() -> Any: - engram = await Engram.open(path=resolved_db) + engram = await Engram.open(path=db_path) + try: mcp_server = build_mcp_server(engram, name="engram") - return build_http_app(engram, mcp_server=mcp_server, auth_token=auth_token) - - fast_app = asyncio.run(_setup()) - - uvicorn.run(fast_app, host=host, port=port, log_level=log_level.lower()) + fast_app = build_http_app(engram, mcp_server=mcp_server, auth_token=auth_token) + config = uvicorn.Config(fast_app, host=host, port=port, log_level=log_level.lower()) + server = uvicorn.Server(config) + await server.serve() + finally: + await engram.close() if __name__ == "__main__": diff --git a/src/engram/server/auth.py b/src/engram/server/auth.py index c7448e3..5fba299 100644 --- a/src/engram/server/auth.py +++ b/src/engram/server/auth.py @@ -60,8 +60,11 @@ async def wrapped(scope: dict[str, Any], receive: ReceiveFn, send: SendFn) -> No header_value = value break - if header_value.startswith(b"Bearer "): - token = header_value[len(b"Bearer ") :].decode("utf-8", errors="replace") + # RFC 9110 §11: auth-scheme tokens are case-insensitive (e.g. Go's oauth2 + # client and curl can emit "bearer" lowercase). Match scheme tolerantly. + scheme, _, rest = header_value.partition(b" ") + if scheme.lower() == b"bearer" and rest: + token = rest.decode("utf-8", errors="replace") if compare_digest(token, expected_token): await app(scope, receive, send) return diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 7df4872..ba6e8b6 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -54,6 +54,22 @@ def test_wrong_token_rejected(): assert resp.status_code == 401 +def test_lowercase_bearer_accepted(): + # RFC 9110 §11: auth-scheme tokens are case-insensitive. Some clients + # (Go oauth2, curl with explicit casing) send "bearer" lowercase. + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/", headers={"Authorization": "bearer secret123"}) + assert resp.status_code == 200 + + +def test_uppercase_bearer_accepted(): + wrapped = auth_asgi_wrapper(_ok_app(), expected_token="secret123") + client = TestClient(wrapped) + resp = client.get("/", headers={"Authorization": "BEARER secret123"}) + assert resp.status_code == 200 + + def test_constant_time_compare_used(monkeypatch): calls = [] import secrets as _secrets