From 8ef64031d52de01b1eb84d4477328d339f04a89b Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Wed, 15 Jul 2026 06:54:21 -0400 Subject: [PATCH] feat(agent-connect): mount MCP-over-HTTP at /mcp on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP-native agents (Claude Code, Cursor, ...) point one URL at the cloud instance (https://team.engraphis.com/mcp) with a per-user bearer token and reuse the same v2 store the dashboard reads — no local install. Team-gated (402) + member-authed (401), exactly like /api/remember. - mcp_server: set_service(svc) injects the dashboard's MemoryService so the MCP tools share ONE writer (no second SQLite connection -> no WAL lock contention). - dashboard_app: build the streamable-http ASGI app up front and give the app a lifespan that initializes the MCP session manager (a mounted sub-app's own lifespan does not run in Starlette -> 'Task group is not initialized'); mount at /mcp with the endpoint at '/' inside the sub-app so the prefix lines up. The mcp import + session-manager build are fully inside a try so the dashboard still starts if the mcp extra is absent (MCP mount is genuinely optional). Reset _session_manager per create_app() so multiple apps in one process (tests) each get a fresh, runnable instance (run() is once-per-instance). - _auth_gate: protect /mcp (Team license 402 + member token/cookie 401) before the existing /api/* logic; disable MCP's DNS-rebinding host allowlist on the mounted instance (default localhost-only would 421 a real domain like team.engraphis.com; the dashboard gate is the real auth boundary; standalone mcp_server_http is unaffected as its own process). - v2_team.connect-info: mcp_over_http + mcp_url now reflect whether /mcp is mounted. - docs/AGENT_CONNECT.md updated (/mcp now live + config snippet); CHANGELOG. - tests/test_agent_connect_mcp.py: 5 tests (401, 402, handshake+tools/list, write-shares-dashboard-store, connect-info reports /mcp). Suite green (one pre-existing flaky inspector test fails under load but passes in isolation, on main too). --- CHANGELOG.md | 19 +++- docs/AGENT_CONNECT.md | 31 +++++- engraphis/dashboard_app.py | 78 ++++++++++++++- engraphis/mcp_server.py | 10 ++ engraphis/routes/v2_team.py | 8 +- tests/test_agent_connect_mcp.py | 162 ++++++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 tests/test_agent_connect_mcp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index baa2a4c..45ac2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/AGENT_CONNECT.md b/docs/AGENT_CONNECT.md index 39b00a4..53053af 100644 --- a/docs/AGENT_CONNECT.md +++ b/docs/AGENT_CONNECT.md @@ -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). \ No newline at end of file +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 " } + } +} +``` + +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.) \ No newline at end of file diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 0b67f0b..1502599 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -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, @@ -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. @@ -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() diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index ec3cf7e..38ff811 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -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 diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index 81977d2..f4d1bc9 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -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"]}, @@ -439,7 +444,8 @@ def connect_info(request: Request): f"Authorization: Bearer \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) diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py new file mode 100644 index 0000000..1281633 --- /dev/null +++ b/tests/test_agent_connect_mcp.py @@ -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")