Skip to content

feat: MCP Streamable HTTP transport (engram-server 0.2.0) - #9

Merged
sunilp merged 13 commits into
mainfrom
feat/mcp-http-transport
May 10, 2026
Merged

sunilp merged 13 commits into
mainfrom
feat/mcp-http-transport

Conversation

@sunilp

@sunilp sunilp commented May 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Ships an engram-server Python binary with MCP Streamable HTTP transport at POST /mcp, mounted alongside the existing /v1/memory/* REST surface on the same FastAPI app. Also ships a new Docker image ghcr.io/jamjet-labs/engram-py-server (separate tag space from the Rust engram-server image) and extends the release workflow to push it to GHCR.

This unblocks JamJet Cloud agents and remote MCP clients (ChatGPT custom connectors, Claude Desktop's HTTP transport) talking to a hosted Engram. Stdio Claude Desktop users keep using the Rust v0.5.0 image — no behavior change there.

Architecture (one-liner per file)

  • src/engram/server/auth.py — bearer-token ASGI middleware (chosen over FastAPI dependency because /mcp is mounted as a sub-ASGI app)
  • src/engram/server/transport.py — wraps mcp.server.Server in StreamableHTTPSessionManager(stateless=True); the only file in the codebase that imports the MCP SDK's transport API
  • src/engram/server/http.py — extended with optional mcp_server and auth_token kwargs; mounts /mcp via Route (not Mount — Mount 307-redirects bare /mcp)
  • src/engram/cli.py — typer-based engram-server; fail-closed on missing token, --no-auth opt-out only with --host 127.0.0.1 or ::1 (localhost rejected)
  • Dockerfile — python:3.13-slim, non-root, /data volume, healthcheck

Auth model (asymmetric, intentional)

Route Auth
/mcp (POST + GET) bearer ENGRAM_AUTH_TOKEN required, fail-closed
/v1/memory/* unauthed (matches Rust REST behavior; deploy behind reverse proxy if you need auth)
/healthz unauthed

The asymmetry is documented in README and guarded by tests/integration/test_v1_rest_unaffected.py so anyone adding /v1 auth later has to update the test deliberately.

Test Plan

  • All baseline tests still pass (315 → 340; +25 new)
  • mypy --strict clean across all new/touched modules
  • ruff check + ruff format check clean
  • python -m build produces wheel + sdist; twine check passes both
  • Clean-venv install: pip install jamjet_engram-0.2.0-py3-none-any.whl then engram-server --version prints 0.2.0
  • Docker build: docker build -t engram-py-server:dev . succeeds
  • Docker smoke: /healthz 200, /mcp 401 with wrong bearer
  • After merge: tag v0.2.0 triggers release workflow then PyPI publish + GHCR push of engram-py-server:0.2.0 and :latest

Out of scope (deferred)

  • Stdio transport in the Python binary (--transport=stdio reserved, currently rejects)
  • OAuth2 dynamic client registration
  • /v1/memory/* auth gating
  • Replacing the Rust engram-server GHCR image
  • Tool surface expansion to match Rust's 11 tools (separate Engram v2 roadmap)

Summary by CodeRabbit

Release Notes: v0.2.0

  • New Features

    • engram-server CLI for hosting Engram as an MCP server over HTTP
    • Bearer token authentication for MCP endpoint via ENGRAM_AUTH_TOKEN; REST memory API remains accessible without authentication
    • Docker image publication to GHCR for each release
  • Documentation

    • Added setup and usage guide for hosted MCP server
    • Updated changelog with 0.2.0 release details
  • Chores

    • Version bump to 0.2.0
    • Optimized Docker build context

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 10, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@sunilp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 13 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff99f2a7-f336-446a-ba23-558b6d310626

📥 Commits

Reviewing files that changed from the base of the PR and between 60427c3 and 984ba38.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/engram/cli.py
  • src/engram/server/auth.py
  • tests/unit/test_auth.py
📝 Walkthrough

Walkthrough

This PR releases version 0.2.0 of jamjet-engram, adding a Python engram-server CLI for hosting MCP tools over Streamable HTTP with bearer-token authentication, containerization support, and comprehensive test coverage. The release includes Docker image building in CI/CD, documentation, and extensive validation testing.

Changes

Engram 0.2.0: MCP Streamable HTTP Server

Layer / File(s) Summary
Package Versioning & Dependencies
pyproject.toml
Version bumped to 0.2.0; typer>=0.12 dependency added; engram-server console script entry point registered pointing to engram.cli:app.
Transport Adapter Contract
src/engram/server/transport.py, tests/unit/test_transport.py
New module provides build_streamable_http_mount(mcp_server) factory returning (manager, handler) tuple; unit tests verify handler identity and manager stateless configuration.
Authentication Wrapper
src/engram/server/auth.py, tests/unit/test_auth.py
New module provides auth_asgi_wrapper(app, expected_token) for constant-time bearer-token validation; gates HTTP requests based on Authorization: Bearer header; unit tests cover token validation, constant-time comparison, and non-HTTP scope passthrough.
CLI Entry Point & Validation
src/engram/cli.py, tests/unit/test_cli.py
New Typer-based CLI enforces HTTP-only transport, loopback-only for --no-auth, fail-closed auth token requirement (from env), and optional --dry-run mode; resolves database path and launches uvicorn; unit tests validate all startup constraints and version flag.
HTTP App Integration
src/engram/server/http.py
build_http_app signature expanded to accept optional mcp_server and auth_token; conditionally mounts /mcp route with async lifespan and bearer-token auth; leaves /v1/memory/* routes unauthenticated.
Integration Tests
tests/integration/test_http_mcp.py, tests/integration/test_v1_rest_unaffected.py
Tests validate /mcp bearer-token enforcement (401 on missing/incorrect token), MCP JSON-RPC workflows (initialize, tools/list, memory record/recall), asymmetric-auth regression (REST routes remain unauthenticated even with MCP auth token), and unknown-tool error handling.
Containerization
Dockerfile, .dockerignore
Dockerfile builds Python 3.13-slim image with non-root user, installs dependencies, configures /data volume for SQLite, exposes 8765, defines HTTP healthcheck, and runs engram-server by default; .dockerignore excludes dev/build/test artifacts.
CI/CD Release Pipeline
.github/workflows/release.yml
New docker job runs after PyPI publish, extracts version from tag, detects prerelease status, builds multi-arch (amd64/arm64) image, and conditionally pushes version + :latest tags (non-prerelease) or version-only (prerelease) to GHCR.
Documentation
README.md, CHANGELOG.md
README adds "Hosted MCP server" quickstart with curl example, bearer-token auth, v0.2 asymmetric-auth model (MCP authed, REST open), and Rust stdio image note; CHANGELOG documents 0.2.0 features, API changes, dependencies, and deferred items.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The PR introduces a cohesive new feature (HTTP MCP server with CLI) involving multiple independent modules (transport, auth, CLI, HTTP app integration) that must work together correctly. The changes are heterogeneous (new modules, API signature changes, Docker configuration, test fixtures) and require understanding the relationships between bearer-token auth, Streamable HTTP session management, CLI validation rules, and the asymmetric-auth model (MCP gated, REST open). However, the changes follow a clear logical narrative and are well-tested, reducing per-file reasoning overhead.

🐰 A server hops into the night,
Bearer tokens held so tight,
Memory flows through /mcp gates,
While REST routes freely celebrate!
✨ Version two takes flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main feature: MCP Streamable HTTP transport with version 0.2.0 release.
Description check ✅ Passed The description is comprehensive, covering Summary, Why, architecture breakdown, auth model, test plan with checkboxes, and out-of-scope items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-http-transport

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/engram/server/http.py (1)

131-146: ⚡ Quick win

Hardcoded version drift risk — derive from package metadata.

version="0.2.0" is duplicated in both branches and will silently desync from pyproject.toml on the next bump. The CLI already pulls the version via importlib.metadata.version("jamjet-engram") for --version; doing the same here keeps a single source of truth and makes the OpenAPI//docs page accurate after a release bump.

♻️ Proposed fix
+from importlib.metadata import PackageNotFoundError, version as _pkg_version
+
+try:
+    _APP_VERSION = _pkg_version("jamjet-engram")
+except PackageNotFoundError:  # editable installs without dist-info
+    _APP_VERSION = "0.0.0+local"
@@
-        app = FastAPI(title="Engram", version="0.2.0", lifespan=lifespan)
+        app = FastAPI(title="Engram", version=_APP_VERSION, lifespan=lifespan)
@@
-        app = FastAPI(title="Engram", version="0.2.0")
+        app = FastAPI(title="Engram", version=_APP_VERSION)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engram/server/http.py` around lines 131 - 146, Replace the hardcoded
version="0.2.0" used when constructing the FastAPI app with a
single-source-of-truth value obtained from package metadata (e.g.
importlib.metadata.version("jamjet-engram")) and reuse that variable in both
branches where FastAPI(...) is created (the branch that sets lifespan and
app.router.routes.append(Route("/mcp", endpoint=_MCPHandler())) and the else
branch), so the OpenAPI/docs version always reflects the package version.
src/engram/cli.py (1)

27-31: 💤 Low value

Use Typer's is_eager callback for --version instead of mid-handler raise.

_print_version() is invoked from inside main, which means it runs after Typer has already parsed every other option (and would conflict if a future option grew side effects in its parser/callback). Typer's idiomatic pattern is an is_eager=True callback that prints and exits before the rest of the command body. Not strictly required — current code works — flagging as nice-to-have.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engram/cli.py` around lines 27 - 31, Change the version handler to an
eager Typer callback so it runs before the main command body: update the
_print_version function signature to accept (value: bool, ctx: typer.Context) or
(ctx, value) per Typer convention, use importlib.metadata.version inside it to
typer.echo the package version and then call raise typer.Exit() (or ctx.exit())
to exit; then register the option on the command (where the --version option is
declared) as typer.Option(False, "--version", help=..., is_eager=True,
callback=_print_version) so Typer invokes _print_version early and avoids
raising inside the main handler.
tests/integration/test_http_mcp.py (2)

25-73: 💤 Low value

Lifespan teardown cancels mid-flight instead of completing graceful shutdown.

The fixture cancels lifespan_task directly, which short-circuits the asynccontextmanager and skips manager.run().__aexit__. For per-test fixtures with fresh Engram instances this is contained, but a graceful path (send lifespan.shutdown and wait for lifespan.shutdown.complete) is closer to what real servers do and avoids surfacing MCP teardown bugs only in production. Optional polish; current behavior is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_http_mcp.py` around lines 25 - 73, The fixture
currently cancels lifespan_task which aborts the ASGI asynccontextmanager;
change _start_lifespan to expose a way to shutdown gracefully (e.g., return the
background Task plus the send channel or a shutdown waiter), and in the app
fixture send a {"type":"lifespan.shutdown"} via the returned send_chan and await
a corresponding shutdown_complete event (handle it in _send by setting
shutdown_complete when "lifespan.shutdown.complete" is received) before
cancelling the task as a last resort; update references to _start_lifespan,
send_chan, startup_complete, and lifespan_task accordingly so teardown performs
send -> wait -> cancel sequence.

230-232: 💤 Low value

Tighten the recall assertion since the test uses deterministic embeddings.

The test's embedding provider (SyntheticEmbedding) is deterministic by design — it produces stable vectors derived from hash-seeded RNG. Since "Alice's dog is named Pepper" is recorded and then recalled with a deterministic query, the result should consistently return the stored text. Replace the permissive assertion with assert "Pepper" in recall_text to catch retrieval regressions.

Note: The ENGRAM_LLM_PROVIDER="mock" environment variable set in the fixture is unused in the codebase and does not affect this test's determinism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_http_mcp.py` around lines 230 - 232, The current
assertion in the test uses a permissive OR allowing "(no matches)" even though
embeddings are deterministic; update the assertion in
tests/integration/test_http_mcp.py that checks recall_text (variable
recall_text) to require the stored phrase is present by replacing the
conditional assert with a strict assertion: assert "Pepper" in recall_text; this
ensures the deterministic embedding provider (SyntheticEmbedding) and the recall
flow return the recorded "Alice's dog is named Pepper" consistently and will
catch regressions in the recall path.
tests/unit/test_cli.py (1)

12-15: 💤 Low value

Brittle version literal will fail on the next bump.

assert "0.2.0" in result.stdout couples this test to today's release; it will need editing on every bump (and silently fail open if someone forgets). Pulling the expected value from the same source the CLI uses keeps the test honest.

♻️ Proposed fix
 def test_version_flag():
+    from importlib.metadata import version as _pkg_version
+
     result = runner.invoke(app, ["--version"])
     assert result.exit_code == 0
-    assert "0.2.0" in result.stdout
+    assert _pkg_version("jamjet-engram") in result.stdout
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_cli.py` around lines 12 - 15, The test test_version_flag is
brittle because it asserts a hard-coded string "0.2.0"; instead import the
canonical version used by the CLI (the same constant the app uses, e.g. the
package __version__ or VERSION exported from the module that defines app) and
assert that str(version) is in result.stdout after runner.invoke(app,
["--version"]); replace the literal with that imported version so the test
follows the source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 17-37: Update the changelog footer links so the "Unreleased"
comparison starts from v0.2.0 and add a release link target for the new [0.2.0]
section: modify the footer link reference for "Unreleased" to compare against
"v0.2.0" instead of the previous base, and add a new link entry for "[0.2.0]"
pointing to the repository release/diff URL for v0.2.0; ensure the identifiers
[0.2.0] and Unreleased in the file match the new footer entries exactly.

In `@src/engram/cli.py`:
- Around line 95-104: The current pattern uses asyncio.run(_setup()) to open
Engram (Engram.open) and then calls uvicorn.run(...) which creates a new event
loop, causing loop-bound resources (aiosqlite) to be attached to a
closed/different loop and never closed; change the flow so the entire lifecycle
stays inside a single asyncio.run: in the async _setup() create the Engram
instance (resolved_db), build_mcp_server and build_http_app, then construct a
uvicorn.Config/uvicorn.Server and await server.serve() inside a try/finally that
calls await engram.close() on shutdown; remove the separate uvicorn.run(...)
call and ensure asyncio.run only wraps the coroutine that both starts the server
and closes Engram.

In `@src/engram/server/auth.py`:
- Around line 57-69: The authorization-scheme check is currently case-sensitive
(header_value.startswith(b"Bearer ")), so update the logic in the auth handling
block that reads header_value to perform a case-insensitive check and token
extraction: convert the header prefix to a normalized form (either decode
header_value first and use .lower().startswith("bearer ") or compare the first
bytes using header_value[:7].lower() == b"bearer "), then slice/parse the token
after the scheme, decode it to a string, and continue to call
compare_digest(token, expected_token) and await app(scope, receive, send) on
success; leave _send_401(send) as the failure path. Ensure you still handle
empty header_value safely.

---

Nitpick comments:
In `@src/engram/cli.py`:
- Around line 27-31: Change the version handler to an eager Typer callback so it
runs before the main command body: update the _print_version function signature
to accept (value: bool, ctx: typer.Context) or (ctx, value) per Typer
convention, use importlib.metadata.version inside it to typer.echo the package
version and then call raise typer.Exit() (or ctx.exit()) to exit; then register
the option on the command (where the --version option is declared) as
typer.Option(False, "--version", help=..., is_eager=True,
callback=_print_version) so Typer invokes _print_version early and avoids
raising inside the main handler.

In `@src/engram/server/http.py`:
- Around line 131-146: Replace the hardcoded version="0.2.0" used when
constructing the FastAPI app with a single-source-of-truth value obtained from
package metadata (e.g. importlib.metadata.version("jamjet-engram")) and reuse
that variable in both branches where FastAPI(...) is created (the branch that
sets lifespan and app.router.routes.append(Route("/mcp",
endpoint=_MCPHandler())) and the else branch), so the OpenAPI/docs version
always reflects the package version.

In `@tests/integration/test_http_mcp.py`:
- Around line 25-73: The fixture currently cancels lifespan_task which aborts
the ASGI asynccontextmanager; change _start_lifespan to expose a way to shutdown
gracefully (e.g., return the background Task plus the send channel or a shutdown
waiter), and in the app fixture send a {"type":"lifespan.shutdown"} via the
returned send_chan and await a corresponding shutdown_complete event (handle it
in _send by setting shutdown_complete when "lifespan.shutdown.complete" is
received) before cancelling the task as a last resort; update references to
_start_lifespan, send_chan, startup_complete, and lifespan_task accordingly so
teardown performs send -> wait -> cancel sequence.
- Around line 230-232: The current assertion in the test uses a permissive OR
allowing "(no matches)" even though embeddings are deterministic; update the
assertion in tests/integration/test_http_mcp.py that checks recall_text
(variable recall_text) to require the stored phrase is present by replacing the
conditional assert with a strict assertion: assert "Pepper" in recall_text; this
ensures the deterministic embedding provider (SyntheticEmbedding) and the recall
flow return the recorded "Alice's dog is named Pepper" consistently and will
catch regressions in the recall path.

In `@tests/unit/test_cli.py`:
- Around line 12-15: The test test_version_flag is brittle because it asserts a
hard-coded string "0.2.0"; instead import the canonical version used by the CLI
(the same constant the app uses, e.g. the package __version__ or VERSION
exported from the module that defines app) and assert that str(version) is in
result.stdout after runner.invoke(app, ["--version"]); replace the literal with
that imported version so the test follows the source of truth.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9cddd9fb-dda7-4cbb-a1f1-677a85773650

📥 Commits

Reviewing files that changed from the base of the PR and between 0d7fc60 and 60427c3.

📒 Files selected for processing (15)
  • .dockerignore
  • .github/workflows/release.yml
  • CHANGELOG.md
  • Dockerfile
  • README.md
  • pyproject.toml
  • src/engram/cli.py
  • src/engram/server/auth.py
  • src/engram/server/http.py
  • src/engram/server/transport.py
  • tests/integration/test_http_mcp.py
  • tests/integration/test_v1_rest_unaffected.py
  • tests/unit/test_auth.py
  • tests/unit/test_cli.py
  • tests/unit/test_transport.py

Comment thread CHANGELOG.md
Comment thread src/engram/cli.py Outdated
Comment thread src/engram/server/auth.py
…casing, changelog links

- 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).
@sunilp
sunilp merged commit d727e47 into main May 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant