Repository navigation
feat: MCP Streamable HTTP transport (engram-server 0.2.0) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7426835
feat(server): bearer-token ASGI auth wrapper for /mcp mount
sunilp 8307c87
feat(server): Streamable HTTP transport adapter (transport.py)
sunilp a4076da
feat(server): mount /mcp Streamable HTTP on FastAPI app with optional…
sunilp ab3ffd5
test(integration): regression guard for /v1 unauthed alongside /mcp g…
sunilp caf6267
test(integration): full MCP handshake + tools/call round-trip over HTTP
sunilp f5bb029
test(integration): tighten lifespan teardown except + fixture param t…
sunilp fb385e5
chore: bump to 0.2.0, add typer dep + engram-server scripts entry
sunilp 0aa3481
feat(cli): engram-server typer CLI with fail-closed auth validation
sunilp 7c5059e
feat(docker): Dockerfile + .dockerignore for engram-py-server image
sunilp b744830
ci: build + push engram-py-server image to GHCR on release
sunilp 39375cb
docs: README hosted-MCP section + CHANGELOG entry for 0.2.0
sunilp 60427c3
chore: ruff E501 line-length fixes across new MCP HTTP transport modules
sunilp 984ba38
fix: address CodeRabbit review — event-loop binding, RFC 9110 scheme …
sunilp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.