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
+58
View File
@@ -0,0 +1,58 @@
"""Public process probes, separate from authenticated project diagnostics."""
import asyncio
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.infra.db.metadb.database import engine
router = APIRouter(include_in_schema=False)
METADATA_TIMEOUT_SECONDS = 2
async def metadata_database_ready() -> bool:
try:
async with asyncio.timeout(METADATA_TIMEOUT_SECONDS):
async with engine.connect() as connection:
result = await connection.execute(text("SELECT 1"))
return result.scalar_one() == 1
except Exception:
# Public probes must not expose connection strings or driver errors.
return False
@router.get("/health")
async def health() -> dict[str, str]:
"""Check HTTP liveness without project or database dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def readiness(request: Request) -> JSONResponse:
"""Check startup, metadata connectivity and the enabled MCP lifecycle."""
started = getattr(request.app.state, "startup_complete", False)
runtime = getattr(request.app.state, "mcp_runtime", None)
mcp_ready = runtime is None or runtime.ready
metadata_ready = await metadata_database_ready() if started else False
ready = started and metadata_ready and mcp_ready
return JSONResponse(
status_code=200 if ready else 503,
content={
"status": "ready" if ready else "not_ready",
"checks": {
"startup": "ready" if started else "not_ready",
"metadata_database": (
"not_checked" if not started
else "ready" if metadata_ready else "unavailable"
),
"mcp_transport": (
"disabled" if runtime is None
else "ready" if mcp_ready else "not_ready"
),
},
},
headers={"Cache-Control": "no-store"},
)
+101
View File
@@ -0,0 +1,101 @@
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
from app.services.agent_messages import AgentMessageService
router = APIRouter(prefix="/agent-messages")
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.post("", response_model=AgentMessage, status_code=201)
async def create_agent_message(
command: AgentMessageCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("", response_model=AgentMessagePage)
async def list_agent_messages(
session_id: str = Query(min_length=1, max_length=500),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
pending_only: bool = False,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
except DomainError as exc:
_raise_domain(exc)
@router.get("/pending", response_model=AgentMessagePage)
async def list_pending_agent_messages(
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).pending(limit, cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/{message_id}", response_model=AgentMessage)
async def get_agent_message(
message_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).get(message_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
async def reply_agent_message(
message_id: UUID, command: AgentMessageReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).reply(message_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/delivery", response_model=AgentMessage)
async def record_agent_message_delivery(
message_id: UUID, command: AgentMessageDelivery,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).delivery(message_id, command)
except DomainError as exc:
_raise_domain(exc)
+60
View File
@@ -0,0 +1,60 @@
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
from fastapi import APIRouter, Header, HTTPException, Request, Response
from psycopg.errors import UndefinedTable, InvalidSchemaName
from app.mcp.models import BindInput, BindResponse
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
router = APIRouter(tags=["MCP Query Context"])
def runtime_for(request: Request):
runtime = getattr(request.app.state, "mcp_runtime", None)
if runtime is None:
raise HTTPException(503, "Product MCP is disabled")
return runtime
@router.post("/mcp-query-contexts", response_model=BindResponse)
async def create_context(
command: BindInput,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> BindResponse:
runtime = runtime_for(request)
principal = await runtime.identity(request)
from app.mcp.contexts import bind_context
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
binding = await bind_context(scope, principal, command, runtime.settings)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return BindResponse(
context_handle=binding.context_handle,
expires_at=binding.expires_at,
task_id=binding.task_id,
run_id=binding.run_id,
)
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
async def delete_context(
context_handle: str,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> Response:
runtime = runtime_for(request)
principal = await runtime.identity(request)
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
context_handle, principal.subject, principal.client_id
)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return Response(status_code=204)
+66 -5
View File
@@ -24,6 +24,7 @@ from app.auth.project_dependencies import (
get_project_pg_connection,
)
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
@@ -70,6 +71,9 @@ from app.domain.schemas.operational_task import (
TaskStatus,
TaskDataQueryResult,
TaskType,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
@@ -92,10 +96,9 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
def _task_permissions(ctx: ProjectContext) -> set[str]:
permissions = set(permissions_for_context(ctx))
if ctx.system_role == "service":
permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW})
return permissions
# Investigations use preconfigured project access, including service accounts.
# Do not force an already-authorized reader through legacy per-task delegation.
return set(permissions_for_context(ctx))
def _raise_domain(exc: DomainError):
@@ -176,6 +179,8 @@ async def list_operational_task_runs(
run_status: Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
] | None = Query(default=None, alias="status"),
task_type: TaskType | None = None,
task_created_from: WorkbenchDateTime | None = None,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
@@ -193,6 +198,8 @@ async def list_operational_task_runs(
try:
items, next_cursor = await _service(ctx, conn).list_runs(
status=run_status,
task_type=task_type,
task_created_from=task_created_from,
limit=limit,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
@@ -202,6 +209,32 @@ async def list_operational_task_runs(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
async def list_task_run_history(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
async def get_task_run(
task_id: UUID, run_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).task_run(task_id, run_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules",
response_model=OperationalTaskSchedule,
@@ -272,7 +305,10 @@ async def recover_operational_task_leases(
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).recover_expired_leases(command.limit)
return await _service(ctx, conn).recover_expired_leases(
command.limit, task_type=command.task_type,
task_created_from=command.task_created_from,
)
except DomainError as exc:
_raise_domain(exc)
@@ -473,6 +509,31 @@ async def publish_operational_task_artifacts(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
async def get_operational_task_presentation_cursor(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_cursor(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
command: TaskPresentationEventCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_presentation_event(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
)
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
from app.services.place_search import PlaceSearchError, search_tianditu_places
router = APIRouter()
@router.post(
"/place-search-requests",
response_model=PlaceSearchResponse,
summary="Search Fengxian places",
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
try:
return await search_tianditu_places(request)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+3 -22
View File
@@ -1,5 +1,5 @@
from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
@@ -18,7 +18,6 @@ from app.services.simulation_scenarios import (
from app.services.valve_isolation import analyze_valve_isolation
from app.domain.time import (
parse_aware_time,
parse_clock_duration_seconds,
parse_utc_time,
)
from pydantic import BaseModel, Field, field_validator
@@ -50,27 +49,9 @@ class PressureRegulation(BaseModel):
def run_simulation_manually_by_date(
network_name: str, start_time: datetime, duration: int
) -> None:
end_datetime = start_time + timedelta(minutes=duration)
time_properties = simulation.get_time(network_name)
hydraulic_step_seconds = parse_clock_duration_seconds(
time_properties["HYDRAULIC TIMESTEP"],
field_name="HYDRAULIC TIMESTEP",
simulation.run_online_simulation(
name=network_name, start_time=start_time, duration_seconds=duration * 60,
)
if hydraulic_step_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
network_name
)
current_time = start_time
while current_time < end_datetime:
simulation.run_simulation(
name=network_name,
simulation_type="realtime",
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
scada_mappings=scada_mappings,
)
current_time += hydraulic_step
# 必须用这个PlainTextResponse,不然每个key都有引号
+2 -1
View File
@@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService
from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
history_router = APIRouter()
@router.post(
@history_router.post(
"/timeseries/views/element-history/query",
summary="批量查询管网元素历史数据",
response_model=ElementHistoryResponse,
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-searches",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+2 -66
View File
@@ -129,72 +129,8 @@ async def workbench_runtime(
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
alert_availability = "degraded"
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
alert_availability=alert_availability,
projection_degraded=False,
)
from app.services.workbench_runtime import read_workbench_runtime
return await read_workbench_runtime(ctx, conn)
async def _sensor_location_map_payload(
+13 -5
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
agent_messages,
audit,
burst_detection,
burst_location,
@@ -12,12 +13,12 @@ from app.api.v1.endpoints import (
meta,
model_import,
operational_tasks,
place_search,
project,
project_data,
scada,
sensor_placement,
simulation,
web_search,
workbench_runtime,
workbench_events,
)
@@ -103,6 +104,7 @@ api_router.include_router(
)
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(agent_messages.router, tags=["Agent Messages"])
api_router.include_router(
admin_metadata.router,
tags=["Metadata Admin"],
@@ -159,13 +161,13 @@ api_router.include_router(
dependencies=[optimization_run_access],
)
api_router.include_router(
web_search.router,
tags=["Web Search"],
geocoding.router,
tags=["Geocoding"],
dependencies=[webgis_view_access],
)
api_router.include_router(
geocoding.router,
tags=["Geocoding"],
place_search.router,
tags=["Place Search"],
dependencies=[webgis_view_access],
)
api_router.include_router(
@@ -195,6 +197,12 @@ for endpoint_router, tag in (
dependencies=[simulation_access],
)
api_router.include_router(
ts_composite.history_router,
tags=["TimescaleDB - Composite"],
dependencies=[Depends(require_permission(SCADA_VIEW))],
)
for endpoint_router, tag in (
(ts_scada.router, "TimescaleDB - SCADA"),
(ts_composite.router, "TimescaleDB - Composite"),