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). 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. 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

Expand Down
35 changes: 30 additions & 5 deletions docs/AGENT_CONNECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,33 @@ 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).
Browser session cookies are deliberately not accepted on this machine-to-machine endpoint.

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:** 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.
148 changes: 146 additions & 2 deletions engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = (
Expand Down Expand Up @@ -52,8 +54,80 @@
"/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:
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:
if importlib.util.find_spec("mcp") is None:
raise ImportError("the optional mcp package is not installed")
import engraphis.mcp_server as _mcp_mod
# 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
_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, 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)

@_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)
app.state.mcp_over_http = False
svc = MemoryService.create(
settings.db_path, embed_model=settings.embed_model,
embed_dim=settings.embed_dim or 256,
Expand Down Expand Up @@ -101,6 +175,10 @@ def create_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
Expand All @@ -119,9 +197,67 @@ 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 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/"):
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:
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)
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.
Expand Down Expand Up @@ -176,6 +312,14 @@ 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)
app.state.mcp_over_http = True

_maybe_start_autosync()
_maybe_start_dreaming()
_maybe_start_license_revalidation()
Expand Down
31 changes: 31 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 All @@ -68,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,
Expand Down
6 changes: 5 additions & 1 deletion engraphis/routes/v2_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +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("/")
# 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"]},
Expand All @@ -439,7 +442,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
11 changes: 6 additions & 5 deletions engraphis/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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)
Expand Down
Loading