feat(server): integrate MCP queries and investigation messaging

Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+41
View File
@@ -0,0 +1,41 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints import agent_messages
from app.auth.project_dependencies import ProjectContext
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import AgentMessageCreate
from app.main import app
@pytest.fixture
def anyio_backend():
return "asyncio"
def test_private_message_and_exact_run_routes_are_in_contract():
paths = app.openapi()["paths"]
assert "post" in paths["/api/v1/agent-messages"]
assert "get" in paths["/api/v1/agent-messages/pending"]
assert "post" in paths["/api/v1/agent-messages/{message_id}/reply"]
assert "post" in paths["/api/v1/agent-messages/{message_id}/delivery"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/runs/{run_id}"]
parameters = paths["/api/v1/agent-messages"]["get"]["parameters"]
assert {"session_id", "cursor", "pending_only"} <= {param["name"] for param in parameters}
@pytest.mark.anyio
async def test_message_route_preserves_structured_terminal_rejection(monkeypatch):
service = SimpleNamespace(create=AsyncMock(side_effect=DomainError(
"RUN_TERMINAL", "Run no longer accepts messages", status=409)))
monkeypatch.setattr(agent_messages, "_service", lambda ctx, conn: service)
context = ProjectContext(uuid4(), "test", uuid4(), "member")
with pytest.raises(HTTPException) as error:
await agent_messages.create_agent_message(AgentMessageCreate(
request_id=uuid4(), task_id=uuid4(), run_id=uuid4(),
source_session_id="main", text="当前情况?"), ctx=context, conn=object())
assert error.value.status_code == 409 and error.value.detail["code"] == "RUN_TERMINAL"
+150
View File
@@ -0,0 +1,150 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import health
@pytest.fixture
def probe_app(monkeypatch):
app = FastAPI()
app.include_router(health.router)
app.state.startup_complete = True
app.state.mcp_runtime = SimpleNamespace(ready=True)
database_check = AsyncMock(return_value=True)
monkeypatch.setattr(health, "metadata_database_ready", database_check)
return app, TestClient(app), database_check
@pytest.mark.parametrize("runtime", [None, SimpleNamespace(ready=True)])
def test_ready_accepts_disabled_or_started_mcp(probe_app, runtime):
app, client, database_check = probe_app
app.state.mcp_runtime = runtime
response = client.get("/ready")
assert response.status_code == 200
assert response.json() == {
"status": "ready",
"checks": {
"startup": "ready", "metadata_database": "ready",
"mcp_transport": "disabled" if runtime is None else "ready",
},
}
assert response.headers["cache-control"] == "no-store"
database_check.assert_awaited_once_with()
def test_startup_and_shutdown_are_not_ready_without_touching_database(probe_app):
app, client, database_check = probe_app
app.state.startup_complete = False
response = client.get("/ready")
assert response.status_code == 503
assert response.json()["checks"]["startup"] == "not_ready"
assert response.json()["checks"]["metadata_database"] == "not_checked"
database_check.assert_not_awaited()
@pytest.mark.parametrize("component", ["metadata_database", "mcp_transport"])
def test_dependency_not_ready_returns_503_without_breaking_liveness(probe_app, component):
app, client, database_check = probe_app
if component == "metadata_database":
database_check.return_value = False
else:
app.state.mcp_runtime.ready = False
response = client.get("/ready")
assert response.status_code == 503
assert response.json()["status"] == "not_ready"
assert response.json()["checks"][component] in {"unavailable", "not_ready"}
database_check.reset_mock()
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
database_check.assert_not_awaited()
@pytest.mark.parametrize("outcome", ["ready", "error", "timeout"])
def test_metadata_probe_runs_bounded_select_and_sanitizes_errors(monkeypatch, outcome):
queries = []
closed = []
async def execute(query):
queries.append(str(query))
if outcome == "error":
raise RuntimeError("postgresql://private:secret@internal/database")
if outcome == "timeout":
await asyncio.Event().wait()
return SimpleNamespace(scalar_one=lambda: 1)
@asynccontextmanager
async def connect():
try:
yield SimpleNamespace(execute=execute)
finally:
closed.append(True)
monkeypatch.setattr(health, "engine", SimpleNamespace(connect=connect))
monkeypatch.setattr(health, "METADATA_TIMEOUT_SECONDS", 0.01)
assert asyncio.run(health.metadata_database_ready()) is (outcome == "ready")
assert queries == ["SELECT 1"]
assert closed == [True]
@pytest.mark.parametrize("startup_fails", [False, True])
def test_application_lifecycle_marks_readiness_before_cleanup(monkeypatch, startup_fails):
from app import main
app = FastAPI()
@asynccontextmanager
async def mcp_lifespan():
assert app.state.startup_complete is False
if startup_fails:
raise RuntimeError("MCP startup failed")
try:
yield
finally:
assert app.state.startup_complete is False
monkeypatch.setattr(main, "mcp_runtime", SimpleNamespace(lifespan=mcp_lifespan))
for owner, method in [
(main.workbench_event_hubs, "close_all"),
(main.project_connection_manager, "close_all"),
(main, "close_metadata_engine"),
]:
monkeypatch.setattr(owner, method, AsyncMock())
monkeypatch.setattr(main, "close_all_timescale_pools", Mock())
monkeypatch.setattr(main, "close_all_project_pools", Mock())
async def run():
if startup_fails:
with pytest.raises(RuntimeError, match="MCP startup failed"):
async with main.lifespan(app):
pytest.fail("Failed startup must not yield")
else:
async with main.lifespan(app):
assert app.state.startup_complete is True
assert app.state.startup_complete is False
asyncio.run(run())
def test_mcp_runtime_tracks_its_actual_lifecycle():
from app.mcp.application import McpQueryApplication
from app.mcp.config import McpSettings
runtime = McpQueryApplication(McpSettings(
enabled=True, issuer_uri="http://localhost/realms/water",
resource_uri="http://localhost/mcp", _env_file=None,
))
async def run():
assert runtime.ready is False
async with runtime.lifespan():
assert runtime.ready is True
assert runtime.ready is False
asyncio.run(run())
@@ -0,0 +1,34 @@
from unittest.mock import AsyncMock
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1.rest_router import api_router
from app.api.v1.endpoints.timeseries import composite
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.domain.schemas.timeseries_history import ElementHistoryResponse
def test_viewer_can_query_history_without_scada_clean_permission(monkeypatch):
project_id = uuid4()
context = ProjectContext(project_id=project_id, project_code="test", user_id=uuid4(), project_role="viewer")
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
app.dependency_overrides[get_project_context] = lambda: context
app.dependency_overrides[composite.get_timescale_connection] = lambda: object()
app.dependency_overrides[composite.get_postgres_connection] = lambda: object()
query = AsyncMock(return_value=ElementHistoryResponse(series=[]))
monkeypatch.setattr(composite.TimeseriesHistoryService, "query", query)
with TestClient(app) as client:
headers = {"X-Project-Id": str(project_id)}
response = client.post("/api/v1/timeseries/views/element-history/query", headers=headers, json={
"start_time": "2026-09-01T00:00:00Z", "end_time": "2026-09-01T01:00:00Z",
"elements": [{"element_type": "junction", "element_id": "J1"}],
})
assert response.status_code == 200
assert response.json() == {"series": []}
assert query.await_count == 1
write_response = client.post("/api/v1/timeseries/scada-cleaning-runs", headers=headers, json={})
assert write_response.status_code == 403
assert write_response.json()["detail"]["permission"] == "scada.clean"
+2 -2
View File
@@ -71,13 +71,13 @@ def test_rest_router_has_unique_method_path_pairs() -> None:
assert len(pairs) == len(set(pairs))
def test_removed_redis_management_routes_are_not_published() -> None:
def test_retired_management_and_web_search_routes_are_not_published() -> None:
published_paths = {
route.path for route in api_router.routes if isinstance(route, APIRoute)
}
assert published_paths.isdisjoint(
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis"}
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis", "/web-searches"}
)
@@ -10,6 +10,7 @@ from app.api.v1.endpoints import operational_tasks as endpoints
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import EvidenceRecord
from app.domain.schemas.operational_task import TaskPresentationEventCommand
from app.main import app
@@ -31,10 +32,34 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"]
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
@pytest.mark.anyio
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
from app.domain.domain_errors import DomainError
service = SimpleNamespace(
publish_presentation_event=AsyncMock(side_effect=DomainError("SESSION_MISMATCH", "stale session", status=409)),
presentation_cursor=AsyncMock(return_value={"task_id": str(uuid4()), "execution_generation": 1,
"active_run": None, "event_cursor": 17}),
)
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
with pytest.raises(HTTPException) as error:
await endpoints.publish_operational_task_presentation_event(
uuid4(), TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="stale",
command={"action": "clear_highlights"}),
ctx=_service_context(), conn=object(),
)
assert error.value.status_code == 409
assert error.value.detail["code"] == "SESSION_MISMATCH"
result = await endpoints.get_operational_task_presentation_cursor(uuid4(), ctx=_service_context(), conn=object())
assert result["event_cursor"] == 17 and result["active_run"] is None
@pytest.mark.anyio
async def test_data_query_requires_task_grant_before_source_request(monkeypatch):
service = SimpleNamespace(can_read_source=AsyncMock(return_value=False))
+65
View File
@@ -0,0 +1,65 @@
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1.endpoints import place_search
from app.api.v1.rest_router import api_router
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse
from app.services.place_search import PlaceSearchError
@pytest.fixture
def app():
application = FastAPI()
application.include_router(api_router, prefix="/api/v1")
application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer")
return application
def test_viewer_can_search_with_existing_project_read_permission(app, monkeypatch):
service = AsyncMock(return_value=PlaceSearchResponse(items=[PlaceSearchItem(
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22,
)], total=1))
monkeypatch.setattr(place_search, "search_tianditu_places", service)
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
assert response.status_code == 200
assert response.json()["items"][0]["name"] == "南桥路"
assert response.json()["region"] == "奉贤区"
assert set(response.json()) == {"items", "total", "truncated", "region"}
assert service.await_args.args[0].keyword == "南桥路"
@pytest.mark.parametrize("status", [502, 503, 504])
def test_endpoint_preserves_provider_failure_status(app, monkeypatch, status):
monkeypatch.setattr(place_search, "search_tianditu_places", AsyncMock(side_effect=PlaceSearchError(status, "查询暂不可用")))
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
assert response.status_code == status and response.json()["detail"] == "查询暂不可用"
@pytest.mark.parametrize("extra", [{"region": "北京市"}, {"specify": "156310104"}, {"offset": 0}, {"limit": 10}])
def test_scope_and_result_limit_cannot_be_overridden(app, monkeypatch, extra):
service = AsyncMock()
monkeypatch.setattr(place_search, "search_tianditu_places", service)
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路", **extra})
assert response.status_code == 422 and not service.called
def test_search_denies_users_without_project_read_permission(app, monkeypatch):
app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "none")
service = AsyncMock()
monkeypatch.setattr(place_search, "search_tianditu_places", service)
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
assert response.status_code == 403 and not service.called
def test_search_contract_has_typed_results_and_required_project_header(app):
contract = app.openapi()
operation = contract["paths"]["/api/v1/place-search-requests"]["post"]
assert any(param["name"] == "X-Project-Id" and param["required"] for param in operation["parameters"])
assert operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchResponse")
assert set(contract["components"]["schemas"]["PlaceSearchRequest"]["properties"]) == {"keyword"}
assert contract["components"]["schemas"]["PlaceSearchResponse"]["properties"]["items"]["maxItems"] == 10
+11 -12
View File
@@ -31,6 +31,7 @@ def _load_simulation_module(monkeypatch):
{
"get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"},
"run_simulation": lambda **kwargs: None,
"run_online_simulation": lambda **kwargs: None,
"query_corresponding_element_id_and_query_id": lambda name: SimpleNamespace(
fixed_pumps={}, variable_pumps={}
),
@@ -145,13 +146,13 @@ def test_pressure_regulation_requires_scheme_name(monkeypatch):
assert response.status_code == 422
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
def test_run_simulation_manually_by_date_passes_aware_time_and_converts_minutes(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured_calls = []
monkeypatch.setattr(
module.simulation,
"run_simulation",
"run_online_simulation",
lambda **kwargs: captured_calls.append(kwargs),
)
@@ -161,13 +162,13 @@ def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
30,
)
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
"2025-01-01T19:04:05+00:00",
"2025-01-01T19:19:05+00:00",
]
assert captured_calls == [{
"name": "demo", "start_time": datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc),
"duration_seconds": 1800,
}]
def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
def test_run_simulation_manually_by_date_preserves_two_hour_window(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured_calls = []
@@ -178,7 +179,7 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
)
monkeypatch.setattr(
module.simulation,
"run_simulation",
"run_online_simulation",
lambda **kwargs: captured_calls.append(kwargs),
)
@@ -188,10 +189,8 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
120,
)
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
"2025-01-01T16:00:00+00:00",
"2025-01-01T17:00:00+00:00",
]
assert captured_calls[0]["duration_seconds"] == 7200
assert captured_calls[0]["start_time"] == datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc)
def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch):