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
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,23 @@ All notable changes to Engraphis are documented here. Format loosely follows
SHA-256 hashed at rest (raw token shown once). A free / lapsed instance returns `402`
on `/api/remember`, so a Team license is required to host team agents. See
`docs/AGENT_CONNECT.md`. (`tests/test_agent_connect.py`.)
*Note:* MCP-over-HTTP at `/mcp` is deferred to a follow-up (needs a service-injection
refactor of `mcp_server.py` to avoid a second SQLite writer); agents use the HTTP API.
*Note:* MCP-over-HTTP at `/mcp` is now mounted (see follow-up entry below), so MCP-native
agents point one URL at the cloud instance too. The HTTP API remains for non-MCP agents.

### Added
- **MCP-over-HTTP at `/mcp` (agent connect, stacked on the above).** The Engraphis MCP
server is mounted at `/mcp` on the dashboard so an MCP-native agent (Claude Code, Cursor)
points one URL at the cloud instance and reuses the same v2 store the dashboard reads.
`mcp_server.set_service(svc)` injects the dashboard's `MemoryService` (one writer — no
second SQLite connection, avoiding the WAL lock contention that `mcp_server_http.py`
exists to prevent). The dashboard app gains a lifespan that initializes the MCP session
manager (a mounted sub-app's own lifespan does not run in Starlette), and `mcp.settings.transport_security`
DNS-rebinding protection is disabled on the mounted instance (the dashboard's `_auth_gate`
is the real boundary, and the default localhost-only allowlist would 421 a real domain).
`/mcp` is Team-gated (402) + member-authenticated (401) exactly like `/api/remember`.
The session manager is reset per `create_app()` so multiple apps in one process (tests)
each get a fresh, runnable instance. `tests/test_agent_connect_mcp.py` (4 tests: 401,
402, handshake+tools/list, write-shares-dashboard-store).

## [0.9.5] - 2026-07-14

Expand Down
31 changes: 26 additions & 5 deletions docs/AGENT_CONNECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,29 @@ is exactly "a Team license is required to host team agents."

## MCP-over-HTTP (`/mcp`)

A streamable-HTTP MCP endpoint at `/mcp` (so MCP-native agents point one URL at the cloud
instance) is planned but **not yet mounted** — mounting it as-is would spin up a second
writer to the same SQLite (WAL lock contention). It needs a service-injection refactor of
`engraphis/mcp_server.py` so it shares the dashboard's `MemoryService`. Until then, agents
use the HTTP API above (most agents can call HTTP via a tool/MCP-shell).
A streamable-HTTP MCP endpoint is mounted at `/mcp` on the dashboard, so an **MCP-native
agent** (Claude Code, Cursor, ...) points one URL at the cloud instance and reuses the same
v2 store the dashboard reads (the MCP tools share the dashboard's single `MemoryService` —
no second SQLite writer). It is Team-gated and member-authenticated exactly like
`/api/remember` (402 without a Team license, 401 without a per-user bearer token).

Agent config (streamable-http transport) — add to your MCP client:

```json
{
"engraphis": {
"url": "https://team.engraphis.com/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
```

The tools are the same as the local `engraphis-mcp` server (`engraphis_remember`,
`engraphis_recall`, `engraphis_start_session`, ...) — an agent gets identical semantics
whether it writes locally or to the cloud.

**Security note:** the dashboard's own `_auth_gate` enforces the Team license + member token
on `/mcp`, so MCP's built-in DNS-rebinding host allowlist (which defaults to localhost only)
is disabled on the mounted instance — otherwise it would reject a real deployment domain
like `team.engraphis.com`. Auth is the real boundary here. (The standalone
`engraphis-mcp-http` launcher is unaffected — it runs in its own process on localhost.)
78 changes: 76 additions & 2 deletions engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,53 @@


def create_app() -> FastAPI:
app = FastAPI(title="Engraphis Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json")
# MCP-over-HTTP agent connect: build the streamable-http ASGI app up front so we can
# give the dashboard a lifespan that initializes its session manager (a mounted
# sub-app's own lifespan does NOT run in Starlette - only the root app's does -
# which is why a naive app.mount('/mcp', mcp.streamable_http_app()) raises
# 'Task group is not initialized'). The endpoint is built at '/' inside the sub-app
# so mounting under /mcp lines up (Starlette strips the mount prefix).
import contextlib as _contextlib
_mcp_asgi = None
_mcp_mgr = None
try:
import engraphis.mcp_server as _mcp_mod
from mcp.server.transport_security import TransportSecuritySettings
# The dashboard's own _auth_gate already enforces Team-license + member-token on
# /mcp, so MCP's DNS-rebinding host allowlist (default: localhost only) is both
# unnecessary here and harmful - it would 421 a real deployment domain
# (team.engraphis.com) and the test client. Disable it for the mounted instance;
# auth is the real boundary. Left set (not restored) because the route guard
# reads it per request; the standalone mcp_server_http.py runs in its own process
# where this mutation does not apply.
_mcp_mod.mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False)
# The MCP session manager's run() is once-per-instance, but create_app() may be
# called more than once in a process (tests, re-import). Reset the lazily-created
# manager so each app gets a fresh, runnable one. No-op for the first call.
try:
_mcp_mod.mcp._session_manager = None
except Exception: # noqa: BLE001 - private attr; stay robust across mcp versions
pass
_prev_path = _mcp_mod.mcp.settings.streamable_http_path
_mcp_mod.mcp.settings.streamable_http_path = "/"
_mcp_asgi = _mcp_mod.mcp.streamable_http_app()
_mcp_mod.mcp.settings.streamable_http_path = _prev_path
_mcp_mgr = _mcp_mod.mcp.session_manager
except Exception as _exc: # noqa: BLE001 - MCP mount stays optional (e.g. mcp not installed)
import sys as _sys
print("[engraphis] MCP /mcp mount skipped: %s" % _exc, file=_sys.stderr)

@_contextlib.asynccontextmanager
async def _lifespan(app: FastAPI):
if _mcp_asgi is not None:
async with _mcp_mgr.run():
yield
else:
yield

app = FastAPI(title="Engraphis Dashboard", docs_url="/api/docs",
openapi_url="/api/openapi.json", lifespan=_lifespan)
svc = MemoryService.create(
settings.db_path, embed_model=settings.embed_model,
embed_dim=settings.embed_dim or 256,
Expand Down Expand Up @@ -119,9 +165,30 @@ async def _auth_gate(request: Request, call_next):
# is exactly "no per-user restriction".
set_current_user(None)
path = request.url.path
if not path.startswith("/api/") or path in _PUBLIC or path.startswith("/api/docs") \
if (not path.startswith("/api/") and not (path == "/mcp" or path.startswith("/mcp/"))) \
or path in _PUBLIC or path.startswith("/api/docs") \
or path.startswith("/api/openapi"):
return await call_next(request)
# MCP-over-HTTP agent endpoint (/mcp) — Team-gated (402 without a Team license)
# and member-authenticated (per-user bearer token or cookie), so "a Team license
# is required to connect" holds for MCP agents exactly as it does for
# /api/remember. The MCP tools then reuse the dashboard's shared MemoryService.
if path == "/mcp" or path.startswith("/mcp/"):
from engraphis.routes.v2_team import _COOKIE
if not (team_enabled and auth_store is not None
and licensing.has_feature("team")):
return JSONResponse({"error": "a Team license is required to connect agents",
"feature": "team", "auth": "team"}, status_code=402)
supplied = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
mu = auth_store.resolve_api_token(supplied) if supplied else None
if mu is None:
mu = auth_store.resolve_session(request.cookies.get(_COOKIE, ""))
if mu is None:
return JSONResponse({"error": "authentication required", "auth": "team"},
status_code=401)
request.state.user = mu
set_current_user(mu)
return await call_next(request)
# Service-account bearer token bypass — skips team auth entirely,
# allowing CI/CD scripts and automation to use the same ENGRAPHIS_API_TOKEN
# regardless of whether team mode is enabled.
Expand Down Expand Up @@ -176,6 +243,13 @@ def index():
import sys
print("[engraphis] ship-safety: %s" % warning, file=sys.stderr)

# Share the dashboard's MemoryService with the MCP server (single writer, no second
# SQLite connection) and mount the pre-built streamable-http app at /mcp. The session
# manager is initialized in the app's lifespan (see _lifespan above).
if _mcp_asgi is not None:
_mcp_mod.set_service(svc)
app.mount("/mcp", _mcp_asgi)

_maybe_start_autosync()
_maybe_start_dreaming()
_maybe_start_license_revalidation()
Expand Down
10 changes: 10 additions & 0 deletions engraphis/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
_service: Optional[MemoryService] = None


def set_service(svc: MemoryService) -> None:
"""Inject an external MemoryService (e.g. the dashboard's) so the MCP tools share
ONE writer with the dashboard instead of opening a second connection to the same
SQLite file (which would cause WAL ``database is locked`` contention — the exact
problem ``scripts/mcp_server_http.py`` was written to avoid). When not injected,
:func:`service` lazily builds a local service (standalone stdio/HTTP MCP)."""
global _service
_service = svc


def service() -> MemoryService:
"""Lazily build the service so server startup is instant (model loads on first use)."""
global _service
Expand Down
8 changes: 7 additions & 1 deletion engraphis/routes/v2_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,11 @@ def connect_info(request: Request):
base = os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip().rstrip("/")
if not base:
base = str(request.base_url).rstrip("/")
try: # /mcp is mounted only when the mcp extra is installed (see dashboard_app.create_app)
import engraphis.mcp_server # noqa: F401
mcp_on = True
except Exception:
mcp_on = False
return {
"user": {"id": u["id"], "email": u["email"], "name": u.get("name", ""),
"role": u["role"]},
Expand All @@ -439,7 +444,8 @@ def connect_info(request: Request):
f"Authorization: Bearer <your-token>\n"
f"POST {base}/api/remember {{\"content\": \"...\", \"workspace\": \"default\"}}\n"
f"GET {base}/api/recall?q=...&workspace=default"),
"mcp_over_http": False, # /mcp mount is a follow-up; agents use HTTP today.
"mcp_over_http": mcp_on,
"mcp_url": (base + "/mcp") if mcp_on else None,
}

app.include_router(router)
Expand Down
162 changes: 162 additions & 0 deletions tests/test_agent_connect_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Agent connect — MCP-over-HTTP at /mcp (stacked on the agent-connect PR).

An MCP-capable agent (Claude Code, Cursor, ...) points one URL at the cloud instance:
``https://team.engraphis.com/mcp`` with a per-user bearer token. The MCP tools reuse the
dashboard's single MemoryService (one writer — no second SQLite connection), so a memory
written via MCP immediately appears in the dashboard. ``/mcp`` is Team-gated (402 without a
Team license) and member-authenticated (401 without a token), matching ``/api/remember``.

These tests speak the streamable-http JSON-RPC protocol directly over the TestClient (no
real socket needed). The dashboard app's lifespan must run (TestClient used as a context
manager) so the MCP session manager's task group initializes.
"""
import time

import pytest

pytest.importorskip("fastapi", reason="full-stack extra not installed")
pytest.importorskip("httpx", reason="httpx not installed")
pytest.importorskip("mcp", reason="mcp extra not installed")

from fastapi.testclient import TestClient # noqa: E402

from engraphis import licensing as lic # noqa: E402
from engraphis.config import settings # noqa: E402
from engraphis.licensing import compose_key, ed25519_public_key # noqa: E402
from engraphis.service import MemoryService # noqa: E402

_SECRET = bytes(range(32))
_PROTO = "2024-11-05"


def _team_key(seats: int = 5) -> str:
return compose_key({"v": 1, "plan": "team", "email": "w@x.co", "seats": seats,
"issued": int(time.time()),
"expires": int(time.time() + 365 * 86400)}, _SECRET)


def _seed(db_path: str) -> None:
MemoryService.create(db_path).remember(
"The team uses Postgres 16 for the main database.", workspace="demo",
scope="workspace", title="DB choice")


def _client(monkeypatch, tmp_path, *, key=None):
db = str(tmp_path / "mcp.db")
monkeypatch.setattr(settings, "db_path", db)
monkeypatch.setattr(settings, "embed_model", "")
monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "")
monkeypatch.setenv("ENGRAPHIS_TEAM_MODE", "1")
monkeypatch.setattr(lic, "_LICENSE_FILE", tmp_path / "license.key")
if key:
monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key)
monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", ed25519_public_key(_SECRET).hex())
else:
monkeypatch.delenv("ENGRAPHIS_LICENSE_KEY", raising=False)
lic.current_license(refresh=True)
_seed(db)
from engraphis.dashboard_app import create_app
return TestClient(create_app())


def _setup_admin(c, email="admin@x.co", password="supersecret1") -> dict:
r = c.post("/api/auth/setup", json={"email": email, "name": "Admin",
"password": password})
assert r.status_code == 200, r.text
return r.json()["user"]


def _mint(c, label="mcp-agent") -> str:
r = c.post("/api/auth/token", json={"label": label})
assert r.status_code == 200, r.text
return r.json()["token"]


def _h(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json",
"Accept": "application/json, text/event-stream"}


def _init(c, token):
"""Run the MCP initialize handshake; return headers carrying the session id."""
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": _PROTO, "capabilities": {},
"clientInfo": {"name": "engraphis-test", "version": "1"}}},
headers=_h(token))
assert r.status_code == 200, r.text
sid = r.headers.get("mcp-session-id")
assert sid, "no mcp-session-id on initialize"
h = {**_h(token), "Mcp-Session-Id": sid}
c.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=h)
return h


def _rpc(c, h, method, params=None, id=2):
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": id, "method": method,
"params": params or {}}, headers=h)
assert r.status_code == 200, r.text
return r


def test_mcp_requires_auth_401(monkeypatch, tmp_path):
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
_setup_admin(c)
c.cookies.clear() # no cookie, no token -> the /mcp gate refuses before MCP
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": _PROTO, "capabilities": {},
"clientInfo": {"name": "t", "version": "1"}}})
assert r.status_code == 401 # no token, no cookie -> refused


def test_mcp_requires_team_license_402(monkeypatch, tmp_path):
# team mode ON but no Team license -> /mcp gates to 402
with _client(monkeypatch, tmp_path, key=None) as c:
_setup_admin(c) # bootstrap admin is exempt from the license gate
token = _mint(c)
c.cookies.clear()
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": _PROTO, "capabilities": {},
"clientInfo": {"name": "t", "version": "1"}}},
headers=_h(token))
assert r.status_code == 402
assert r.json()["feature"] == "team"


def test_mcp_handshake_lists_engraphis_tools(monkeypatch, tmp_path):
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
_setup_admin(c)
token = _mint(c)
c.cookies.clear()
h = _init(c, token)
r = _rpc(c, h, "tools/list")
assert "engraphis_remember" in r.text
assert "engraphis_recall" in r.text


def test_mcp_write_shares_the_dashboard_store(monkeypatch, tmp_path):
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
_setup_admin(c)
token = _mint(c)
c.cookies.clear()
h = _init(c, token)
# write a memory via the MCP tool ...
r = _rpc(c, h, "tools/call",
{"name": "engraphis_remember",
"arguments": {"content": "MCP wrote this cloud memory",
"workspace": "demo"}}, id=10)
assert "stored" in r.text
# ... and it is immediately recallable through the dashboard's HTTP API
rec = c.get("/api/recall?q=MCP&workspace=demo", headers=_h(token))
assert rec.status_code == 200
assert any("MCP" in (m.get("content") or "")
for m in rec.json()["memories"])

def test_connect_info_reports_mcp_available(monkeypatch, tmp_path):
with _client(monkeypatch, tmp_path, key=_team_key()) as c:
_setup_admin(c)
token = _mint(c)
c.cookies.clear()
ci = c.get("/api/auth/connect-info", headers=_h(token)).json()
assert ci["mcp_over_http"] is True
assert ci["mcp_url"].endswith("/mcp")