From 1b86d37d3e3e46db15981878cb76473c1679e41a Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Wed, 15 Jul 2026 06:54:21 -0400 Subject: [PATCH 1/3] 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") From 5f91c3a88fdb3e5b0e50a0340f5ab3be1ada2f99 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 15 Jul 2026 22:21:57 -0400 Subject: [PATCH 2/3] fix(agent-connect): secure MCP HTTP sessions --- CHANGELOG.md | 12 ++-- docs/AGENT_CONNECT.md | 14 ++-- engraphis/dashboard_app.py | 108 ++++++++++++++++++++++++------ engraphis/mcp_server.py | 21 ++++++ engraphis/routes/v2_team.py | 8 +-- engraphis/service.py | 11 ++-- tests/test_agent_connect_mcp.py | 112 +++++++++++++++++++++++++++++++- 7 files changed, 243 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ac2ff..b46ab19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,13 +28,13 @@ All notable changes to Engraphis are documented here. Format loosely follows `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`. + manager (a mounted sub-app's own lifespan does not run in Starlette). MCP's DNS-rebinding + protection remains enabled: loopback stays allowed, and `ENGRAPHIS_DASHBOARD_URL` adds + the deployment's exact Host + Origin. `/mcp` is Team-gated (402), bearer-only (401), + and role-aware: viewers can read, members can mutate, and maintenance remains admin-only. 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). + each get a fresh, runnable instance. Capability discovery reports the actual mount state, + not merely whether the optional package imports. `tests/test_agent_connect_mcp.py`. ## [0.9.5] - 2026-07-14 diff --git a/docs/AGENT_CONNECT.md b/docs/AGENT_CONNECT.md index 53053af..5111058 100644 --- a/docs/AGENT_CONNECT.md +++ b/docs/AGENT_CONNECT.md @@ -112,6 +112,7 @@ agent** (Claude Code, Cursor, ...) points one URL at the cloud instance and reus 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). +Browser session cookies are deliberately not accepted on this machine-to-machine endpoint. Agent config (streamable-http transport) — add to your MCP client: @@ -128,8 +129,11 @@ 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 +**Security note:** MCP's built-in DNS-rebinding protection remains enabled. Loopback hosts +remain allowed by default; a hosted deployment must set `ENGRAPHIS_DASHBOARD_URL` to its +canonical public URL (for example, `https://team.engraphis.com`) so that exact Host and +Origin are added to the transport allowlist. Requests with any other Host are rejected. +The per-user bearer token is checked on every request, and dashboard roles carry through to +tools: viewers may use read tools, members may use mutating tools, and +`engraphis_consolidate` requires admin. The standalone `engraphis-mcp-http` launcher keeps +its own SDK defaults. diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 1502599..8ac5b87 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -7,7 +7,9 @@ from __future__ import annotations import hmac +import importlib.util from pathlib import Path +from urllib.parse import urlsplit import os as _os _os.environ["ENGRAPHIS_EMBED_MODEL"] = ( @@ -52,6 +54,31 @@ "/webhooks/polar"} +def _mcp_transport_security(mcp): + """Keep the SDK's DNS-rebinding guard and add this deployment's public URL.""" + from mcp.server.transport_security import TransportSecuritySettings + + current = mcp.settings.transport_security + allowed_hosts = set(current.allowed_hosts) + allowed_origins = set(current.allowed_origins) + dashboard_url = _os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip() + if dashboard_url: + parsed = urlsplit(dashboard_url) + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username: + raise ValueError("ENGRAPHIS_DASHBOARD_URL must be an http(s) URL without userinfo") + hostname = parsed.hostname + host = "[%s]" % hostname if ":" in hostname else hostname + if parsed.port is not None: + host = "%s:%d" % (host, parsed.port) + allowed_hosts.add(host) + allowed_origins.add("%s://%s" % (parsed.scheme, host)) + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=sorted(allowed_hosts), + allowed_origins=sorted(allowed_origins), + ) + + def create_app() -> FastAPI: # 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 @@ -63,17 +90,9 @@ def create_app() -> FastAPI: _mcp_asgi = None _mcp_mgr = None try: + if importlib.util.find_spec("mcp") is None: + raise ImportError("the optional mcp package is not installed") 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. @@ -82,11 +101,19 @@ def create_app() -> FastAPI: 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 + _prev_security = _mcp_mod.mcp.settings.transport_security + try: + _mcp_mod.mcp.settings.streamable_http_path = "/" + _mcp_mod.mcp.settings.transport_security = _mcp_transport_security(_mcp_mod.mcp) + _mcp_asgi = _mcp_mod.mcp.streamable_http_app() + finally: + # streamable_http_app() captures these settings in its session manager. Restore + # the global FastMCP instance so importing the dashboard cannot alter the + # standalone MCP server in the same process. + _mcp_mod.mcp.settings.streamable_http_path = _prev_path + _mcp_mod.mcp.settings.transport_security = _prev_security _mcp_mgr = _mcp_mod.mcp.session_manager - except Exception as _exc: # noqa: BLE001 - MCP mount stays optional (e.g. mcp not installed) + except (Exception, SystemExit) as _exc: # MCP mount stays optional (e.g. no mcp extra) import sys as _sys print("[engraphis] MCP /mcp mount skipped: %s" % _exc, file=_sys.stderr) @@ -100,6 +127,7 @@ async def _lifespan(app: FastAPI): app = FastAPI(title="Engraphis Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=_lifespan) + app.state.mcp_over_http = False svc = MemoryService.create( settings.db_path, embed_model=settings.embed_model, embed_dim=settings.embed_dim or 256, @@ -147,6 +175,10 @@ async def _lifespan(app: FastAPI): team_enabled, auth_store = v2_team.attach(app, svc) except Exception: # noqa: BLE001 - team stays optional pass + # Streamable HTTP sessions are process-local in the MCP SDK. Bind each one to the + # authenticated user that initialized it so another valid member cannot replay a + # stolen session id with their own bearer token. + _mcp_session_users: dict[str, str] = {} def _bearer_ok(request: Request) -> bool: token = settings.api_token @@ -170,25 +202,62 @@ async def _auth_gate(request: Request, call_next): 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 + # and member-authenticated with a per-user bearer token, 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) + if not app.state.mcp_over_http: + return JSONResponse({"error": "MCP-over-HTTP is unavailable"}, + status_code=404) + session_id = (request.headers.get("Mcp-Session-Id") or "").strip() + if session_id: + owner_id = _mcp_session_users.get(session_id) + if owner_id is None: + return JSONResponse({"error": "unknown MCP session"}, status_code=401) + if owner_id != mu["id"]: + return JSONResponse({"error": "MCP session belongs to another user"}, + status_code=403) + + # Roles must be evaluated on every HTTP request, not captured when the MCP + # session starts: a token owner's role or disabled state can change while the + # session remains open. Reading the JSON body here is safe with Starlette's + # BaseHTTPMiddleware request wrapper; call_next receives the cached body. + if request.method == "POST": + try: + payload = await request.json() + except Exception: # noqa: BLE001 - the MCP SDK returns its protocol error + payload = None + messages = payload if isinstance(payload, list) else [payload] + from engraphis.inspector.auth import role_at_least + for message in messages: + if not isinstance(message, dict) or message.get("method") != "tools/call": + continue + params = message.get("params") + tool_name = params.get("name", "") if isinstance(params, dict) else "" + minimum = _mcp_mod.minimum_role(str(tool_name)) + if not role_at_least(mu.get("role", ""), minimum): + return JSONResponse({"error": "requires the %s role" % minimum}, + status_code=403) request.state.user = mu set_current_user(mu) - return await call_next(request) + response = await call_next(request) + response_session = (response.headers.get("Mcp-Session-Id") or "").strip() + if response_session: + existing = _mcp_session_users.setdefault(response_session, mu["id"]) + if existing != mu["id"]: # pragma: no cover - defensive collision guard + return JSONResponse({"error": "MCP session collision"}, status_code=409) + if request.method == "DELETE" and session_id and response.status_code < 400: + _mcp_session_users.pop(session_id, None) + return response # 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. @@ -249,6 +318,7 @@ def index(): if _mcp_asgi is not None: _mcp_mod.set_service(svc) app.mount("/mcp", _mcp_asgi) + app.state.mcp_over_http = True _maybe_start_autosync() _maybe_start_dreaming() diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 38ff811..be515ac 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -78,6 +78,27 @@ def _err(exc: Exception) -> str: return f"Error: {type(exc).__name__}: {exc}" +_READ_ONLY_TOOLS = frozenset({ + "engraphis_recall", + "engraphis_recall_grounded", + "engraphis_why", + "engraphis_timeline", + "engraphis_recall_proactive", + "engraphis_search_code", + "engraphis_stats", +}) +_ADMIN_TOOLS = frozenset({"engraphis_consolidate"}) + + +def minimum_role(tool_name: str) -> str: + """Dashboard role required for an MCP tool; unknown/new tools default to member.""" + if tool_name in _ADMIN_TOOLS: + return "admin" + if tool_name in _READ_ONLY_TOOLS: + return "viewer" + return "member" + + @mcp.tool( name="engraphis_remember", annotations={"title": "Remember a fact", "readOnlyHint": False, diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index f4d1bc9..13cf860 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -428,11 +428,9 @@ 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 + # Importability is not availability: the MCP package can be installed while app + # construction still refuses the mount (for example, an invalid public URL). + mcp_on = bool(getattr(request.app.state, "mcp_over_http", False)) return { "user": {"id": u["id"], "email": u["email"], "name": u.get("name", ""), "role": u["role"]}, diff --git a/engraphis/service.py b/engraphis/service.py index 5e720ff..fa8cab8 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -54,9 +54,10 @@ class ValidationError(ValueError): # Set by the dashboard's team auth gate (engraphis/dashboard_app.py::_auth_gate) for the # duration of a request, and read at the workspace-authorization chokepoint below so a # *personal* folder is visible and usable only by its owner. Every other entry point — -# the MCP server, the CLI, the sync loop, and the offline test/eval harnesses — leaves -# this at its ``None`` default, so per-user enforcement is a no-op outside the multi-user -# dashboard and single-tenant behaviour is completely unchanged. It lives here (not in a +# standalone MCP server, the CLI, the sync loop, and the offline test/eval harnesses — +# leaves this at its ``None`` default, so per-user enforcement is a no-op outside the +# multi-user dashboard (including its mounted MCP endpoint) and single-tenant behaviour is +# completely unchanged. It lives here (not in a # route module) so the service stays the single place workspace access is decided. _CURRENT_USER: "contextvars.ContextVar[Optional[dict]]" = contextvars.ContextVar( "engraphis_dashboard_user", default=None) @@ -400,7 +401,7 @@ def _workspace_visibility(self, ws: str) -> tuple[str, str]: def _enforce_personal_access(self, ws: str) -> None: """Block access to another user's personal folder. No current user (single-tenant, - MCP, CLI, sync, tests) → no restriction. A shared folder, or a personal folder the + standalone MCP, CLI, sync, tests) → no restriction. A shared folder, or a personal folder the current user owns → allowed. A personal folder owned by someone else → refused, with a message that neither confirms nor denies the folder's contents beyond the fact that it's private (the name is already known to the caller who supplied it).""" @@ -1068,7 +1069,7 @@ def create_workspace(self, name: str, description: str = "", ``visibility`` is ``'shared'`` (default — the whole team can see and use it) or ``'personal'`` (visible and usable only by the creating user, enforced by ``_authorize_workspace``). Personal requires a signed-in dashboard user to own it; - if there is no current user (single-tenant / MCP / CLI) a ``personal`` request + if there is no current user (single-tenant / standalone MCP / CLI) a ``personal`` request degrades to ``shared`` rather than minting an owner-less folder nobody could ever reach.""" ws = self._clean_ws(name) diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py index 1281633..b7dae93 100644 --- a/tests/test_agent_connect_mcp.py +++ b/tests/test_agent_connect_mcp.py @@ -47,6 +47,7 @@ def _client(monkeypatch, tmp_path, *, key=None): monkeypatch.setattr(settings, "embed_model", "") monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "") monkeypatch.setenv("ENGRAPHIS_TEAM_MODE", "1") + monkeypatch.setenv("ENGRAPHIS_DASHBOARD_URL", "http://testserver") monkeypatch.setattr(lic, "_LICENSE_FILE", tmp_path / "license.key") if key: monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key) @@ -92,10 +93,10 @@ def _init(c, token): return h -def _rpc(c, h, method, params=None, id=2): +def _rpc(c, h, method, params=None, id=2, status_code=200): r = c.post("/mcp", json={"jsonrpc": "2.0", "id": id, "method": method, "params": params or {}}, headers=h) - assert r.status_code == 200, r.text + assert r.status_code == status_code, r.text return r @@ -109,13 +110,22 @@ def test_mcp_requires_auth_401(monkeypatch, tmp_path): assert r.status_code == 401 # no token, no cookie -> refused +def test_mcp_rejects_browser_cookie_without_bearer(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) # leaves a valid dashboard session cookie in the client + 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 + + 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", + r = c.post("/mcp/", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": _PROTO, "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}, headers=_h(token)) @@ -123,6 +133,18 @@ def test_mcp_requires_team_license_402(monkeypatch, tmp_path): assert r.json()["feature"] == "team" +def test_mcp_rejects_unconfigured_host(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + 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), "Host": "attacker.invalid"}) + assert r.status_code == 421 + + def test_mcp_handshake_lists_engraphis_tools(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) @@ -134,6 +156,79 @@ def test_mcp_handshake_lists_engraphis_tools(monkeypatch, tmp_path): assert "engraphis_recall" in r.text +def test_mcp_tools_enforce_team_roles(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + admin_token = _mint(c, label="admin-agent") + member = c.post("/api/auth/users", json={ + "email": "agent@x.co", "name": "Agent", "password": "memberpass1", + "role": "member", + }).json()["user"] + + c.post("/api/auth/logout") + assert c.post("/api/auth/login", json={ + "email": "agent@x.co", "password": "memberpass1", + }).status_code == 200 + token = _mint(c, label="role-agent") + + # Existing tokens immediately inherit role changes. Demote this token's owner to + # viewer, then prove reads still work while a mutating MCP tool is refused. + c.post("/api/auth/logout") + assert c.post("/api/auth/login", json={ + "email": "admin@x.co", "password": "supersecret1", + }).status_code == 200 + assert c.post("/api/auth/users/update", json={ + "user_id": member["id"], "role": "viewer", + }).status_code == 200 + c.cookies.clear() + h = _init(c, token) + assert "Postgres 16" in _rpc( + c, h, "tools/call", + {"name": "engraphis_recall", + "arguments": {"query": "database", "workspace": "demo"}}, + id=20, + ).text + denied = _rpc( + c, h, "tools/call", + {"name": "engraphis_remember", + "arguments": {"content": "viewer must not write", "workspace": "demo"}}, + id=21, + status_code=403, + ) + assert "requires the member role" in denied.text + hijack = _rpc( + c, {**h, "Authorization": f"Bearer {admin_token}"}, "tools/list", + id=24, + status_code=403, + ) + assert "belongs to another user" in hijack.text + + # Promote the same token owner back to member. Writes are now allowed, but the + # admin-only consolidation sweep remains unavailable. + assert c.post("/api/auth/login", json={ + "email": "admin@x.co", "password": "supersecret1", + }).status_code == 200 + assert c.post("/api/auth/users/update", json={ + "user_id": member["id"], "role": "member", + }).status_code == 200 + c.cookies.clear() + allowed = _rpc( + c, h, "tools/call", + {"name": "engraphis_remember", + "arguments": {"content": "member may write", "workspace": "demo"}}, + id=22, + ) + assert "stored" in allowed.text + admin_only = _rpc( + c, h, "tools/call", + {"name": "engraphis_consolidate", + "arguments": {"workspace": "demo", "dry_run": True}}, + id=23, + status_code=403, + ) + assert "requires the admin role" in admin_only.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) @@ -160,3 +255,14 @@ def test_connect_info_reports_mcp_available(monkeypatch, tmp_path): ci = c.get("/api/auth/connect-info", headers=_h(token)).json() assert ci["mcp_over_http"] is True assert ci["mcp_url"].endswith("/mcp") + + +def test_connect_info_reports_actual_mount_state(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + token = _mint(c) + c.cookies.clear() + c.app.state.mcp_over_http = False + ci = c.get("/api/auth/connect-info", headers=_h(token)).json() + assert ci["mcp_over_http"] is False + assert ci["mcp_url"] is None From 97df35bb0e7ca0b0f2a6c5f22fac16696bf7893e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 15 Jul 2026 22:34:31 -0400 Subject: [PATCH 3/3] test(agent-connect): isolate unlicensed MCP gate --- tests/test_agent_connect_mcp.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py index b7dae93..3c8daf6 100644 --- a/tests/test_agent_connect_mcp.py +++ b/tests/test_agent_connect_mcp.py @@ -122,13 +122,11 @@ def test_mcp_rejects_browser_cookie_without_bearer(monkeypatch, tmp_path): 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() + # Entitlement is checked before authentication, so this test needs no licensed + # bootstrap user or valid token (and must not depend on a developer's local key). r = c.post("/mcp/", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": _PROTO, "capabilities": {}, - "clientInfo": {"name": "t", "version": "1"}}}, - headers=_h(token)) + "clientInfo": {"name": "t", "version": "1"}}}) assert r.status_code == 402 assert r.json()["feature"] == "team"