Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>` (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).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [0.1.0] - 2026-05-06

### Added
Expand Down Expand Up @@ -119,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
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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.
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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"
Expand Down
135 changes: 135 additions & 0 deletions src/engram/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""`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

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

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

from engram.engram import Engram
from engram.server.http import build_http_app
from engram.server.mcp import build_mcp_server

engram = await Engram.open(path=db_path)
try:
mcp_server = build_mcp_server(engram, name="engram")
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__":
app()
Loading
Loading