Skip to content
15 changes: 14 additions & 1 deletion engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,16 +382,24 @@ def create_app() -> FastAPI:
pass
_prev_path = _mcp_mod.mcp.settings.streamable_http_path
_prev_security = _mcp_mod.mcp.settings.transport_security
_prev_stateless = getattr(_mcp_mod.mcp.settings, "stateless_http", False)
try:
_mcp_mod.mcp.settings.streamable_http_path = "/"
_mcp_mod.mcp.settings.transport_security = _mcp_transport_security(_mcp_mod.mcp)
# Hosted callers can bind a tenant service and principal around each HTTP
# operation. Stateful Streamable HTTP runs tool callbacks in the persistent
# initialization task, which would retain the first request's ContextVars
# for later requests. Stateless mode keeps every callback in its request
# context and is therefore required for request-scoped service isolation.
_mcp_mod.mcp.settings.stateless_http = True
_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_mod.mcp.settings.stateless_http = _prev_stateless
_mcp_mgr = _mcp_mod.mcp.session_manager
except (Exception, SystemExit) as _exc: # noqa: BLE001 - MCP mount stays optional
import logging as _logging
Expand Down Expand Up @@ -1284,10 +1292,15 @@ def cancel_obsidian_job_alias(job_id: str, request: Request, workspace: str = Fo
@app.middleware("http")
async def _auth_gate(request: Request, call_next):
from engraphis.service import set_current_user
from engraphis.service_context import bound_service

# The open runtime has no hosted identity model. Clear any context inherited from
# embedding applications and authorize the whole local instance as one principal.
set_current_user(None)
# An embedding host may instead bind a tenant service and validated principal for
# this request; preserve that identity so personal-workspace enforcement remains
# active through dashboard and mounted MCP dispatch.
if bound_service() is None:
set_current_user(None)
path = request.url.path
if request.method == "OPTIONS":
return await call_next(request)
Expand Down
60 changes: 60 additions & 0 deletions tests/test_dashboard_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,57 @@ def test_dashboard_create_workspace_succeeds_when_unbound(monkeypatch, tmp_path)
assert response.json()["created"] is True


def test_dashboard_preserves_hosted_principal_for_personal_access(
monkeypatch, tmp_path,
):
import anyio
import httpx

from engraphis.dashboard_app import create_app
from engraphis.service import current_user, set_current_user
from engraphis.service_context import bind_service

monkeypatch.setattr(settings, "db_path", str(tmp_path / "hosted-dashboard.db"))
monkeypatch.setattr(settings, "embed_model", "")
monkeypatch.setattr(settings, "embed_dim", 384)
monkeypatch.setattr(settings, "allowed_workspaces", [])
monkeypatch.setattr(settings, "api_token", "")
app = create_app()
hosted = MemoryService.create(":memory:", extractor="none")
owner = {"id": "member_bob", "email": "bob@example.test", "role": "member"}
principal = {"id": "member_alice", "email": "alice@example.test", "role": "member"}
set_current_user(owner)
hosted.create_workspace("bob-private", visibility="personal")
set_current_user(None)

@app.get("/api/test-hosted-personal", include_in_schema=False)
def hosted_personal_probe():
from engraphis.routes.v2_api import service
from engraphis.service import ValidationError

try:
service()._enforce_personal_access("bob-private")
except ValidationError:
allowed = False
else:
allowed = True
return {"allowed": allowed, "user": current_user()}

async def request_probe():
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 50000))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/api/test-hosted-personal")

try:
with bind_service(hosted, principal=principal):
response = anyio.run(request_probe)
assert response.status_code == 200, response.text
assert response.json() == {"allowed": False, "user": principal}
finally:
hosted.close()
app.state.service.close()


def test_dashboard_ignores_legacy_workspace_binding_setting(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "db_path", str(tmp_path / "legacy-binding.db"))
monkeypatch.setattr(settings, "embed_model", "")
Expand Down Expand Up @@ -876,6 +927,15 @@ def test_dashboard_and_mcp_recall_share_the_v2_service(monkeypatch, tmp_path):
assert dashboard["score_semantics"] == mcp["score_semantics"]


def test_dashboard_mcp_mount_is_stateless_for_request_scoped_contexts(monkeypatch, tmp_path):
pytest.importorskip("mcp", reason="MCP extra not installed")
from engraphis import mcp_server

with _client(monkeypatch, tmp_path) as client:
assert client.app.state.mcp_over_http is True
assert mcp_server.mcp.session_manager.stateless is True


def test_dashboard_keyword_fallback_reports_truthful_lexical_scores(monkeypatch, tmp_path):
with _client(monkeypatch, tmp_path) as client:
def mismatched_embedder(*_args, **_kwargs):
Expand Down