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
View File
+139
View File
@@ -0,0 +1,139 @@
"""Bounded calculation inputs; identity, routing and output paths are host-owned."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.mcp.models import Id, StrictInput
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
class CalculationInput(StrictInput):
timeout_seconds: int = Field(
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
)
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
@model_validator(mode="after")
def valid_target(self):
if self.target_time.tzinfo is None:
raise ValueError("target_time requires explicit timezone")
if 1440 % self.sampling_interval_minutes:
raise ValueError("sampling interval must divide a day")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class BurstLocationInput(CalculationInput):
data_source: Literal["monitoring", "simulation"] = "monitoring"
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
scada_burst_start: datetime
scada_burst_end: datetime
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
@model_validator(mode="after")
def valid_windows(self):
pairs = [(self.scada_burst_start, self.scada_burst_end)]
if self.data_source == "monitoring":
if self.simulation_run_id is not None:
raise ValueError("monitoring cannot use simulation_run_id")
if self.scada_normal_start is not None or self.scada_normal_end is not None:
pairs.append((self.scada_normal_start, self.scada_normal_end))
elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None:
raise ValueError("simulation requires run_id and uses the same-window model baseline")
for start, end in pairs:
if start is None or end is None or start.tzinfo is None or end.tzinfo is None:
raise ValueError("explicit timezone-aware burst and normal windows are required")
if not timedelta(0) < end - start <= timedelta(days=1):
raise ValueError("each window must be positive and at most 24 hours")
for ids in (self.pressure_scada_ids, self.flow_scada_ids):
if ids is not None and len(set(ids)) != len(ids):
raise ValueError("sensor IDs must be distinct")
return self
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
class LeakageIdentificationInput(CalculationInput):
scada_start: datetime
scada_end: datetime
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值")
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
dma_count: int = Field(ge=1, le=20)
@model_validator(mode="after")
def valid_window(self):
if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
duration = self.scada_end - self.scada_start
if not timedelta(0) < duration <= timedelta(days=1):
raise ValueError("window must be positive and at most 24 hours")
if duration.total_seconds() % (self.timestep * 60):
raise ValueError("window must contain whole calculation steps")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class ScenarioInput(CalculationInput):
start_time: datetime
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响")
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def timezone_required(self):
if self.start_time.tzinfo is None:
raise ValueError("start_time requires explicit timezone")
return self
class ContaminantSimulationInput(ScenarioInput):
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源")
class ValveClosureInput(ScenarioInput):
valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制")
class FlushingInput(ScenarioInput):
drainage_node_id: Id
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)]
class PressureRegulationInput(ScenarioInput):
pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化")
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
class SensorPlacementInput(CalculationInput):
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(ge=1, le=20)
min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm")
+69
View File
@@ -0,0 +1,69 @@
"""Side-effect-limited adapters for existing domain calculations."""
import asyncio
from threading import BoundedSemaphore
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel
from app.mcp.scenario_service import SCENARIO_TOOLS, calculate_scenario
# These services synchronously read SCADA/model series or write online results.
# Topology-only isolation and model-based sensor placement need only BizDB.
TIMESCALE_CALCULATIONS = SCENARIO_TOOLS | frozenset({
"run_burst_detection", "run_burst_location", "run_leakage_identification",
})
# A timed-out HTTP request cannot kill a native algorithm thread. Keep its slot
# until the calculation really exits, so retries cannot accumulate CPU jobs.
_slots = BoundedSemaphore(2)
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
if not _slots.acquire(blocking=False):
raise HTTPException(503, "Calculation capacity is occupied")
try:
if name in SCENARIO_TOOLS:
return calculate_scenario(name, parameters, network=network, username=username)
if name == "optimize_sensor_placement":
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
parameters = dict(parameters)
method = parameters.pop("method")
optimizer = optimize_sensor_placement_by_sensitivity if method == "sensitivity" else optimize_sensor_placement_by_kmeans
return optimizer(project_code=network, run_name="MCP preview", created_by=username,
persist_results=False, **parameters)
if name == "run_leakage_identification":
from app.services.dma_leakage_estimation import run_leakage_identification
duration = (parameters["scada_end"] - parameters["scada_start"]).total_seconds() / 3600
return run_leakage_identification(
network=network, username=username, scheme_name=None,
duration=duration, q_sum_unit="m3/s", output_flow_unit="m3/s",
n_workers=1, pop_size=50, max_gen=100, **parameters,
)
if name == "run_burst_detection":
from app.services.burst_detection import run_burst_detection
return run_burst_detection(network=network, username=username, scheme_name=None, **parameters)
if name == "run_burst_location":
from app.services.burst_location import run_burst_location_by_network
return run_burst_location_by_network(
network=network, username=username, scheme_name=None,
final_candidates_csv_path=None, **parameters,
)
if name == "analyze_valve_isolation":
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
async def calculate(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
# to_thread copies the validated project's routing context. Never accept
# database routes, actor identity, arbitrary measurements or paths from MCP.
return await asyncio.to_thread(
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
)
+505
View File
@@ -0,0 +1,505 @@
"""Official SDK transport with product authorization, persistence and query handlers."""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from urllib.parse import urlsplit
from uuid import uuid5, NAMESPACE_URL
from typing import Any
from fastapi import HTTPException
from mcp import types
from mcp.server import Server
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.settings import AuthSettings
from mcp.server.auth.routes import create_protected_resource_routes
from mcp.server.caching import CacheHint
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from psycopg.errors import UndefinedTable, InvalidSchemaName
from pydantic import ValidationError
from starlette.responses import JSONResponse
from app.mcp.auth import (
McpTokenVerifier,
QueryScopeMiddleware,
QUERY_SCOPE,
CONTEXT_SCOPE,
challenge,
)
from app.mcp.catalog import TOOLS
from app.mcp.config import McpSettings
from app.mcp.contexts import resolve_context, verify_task
from app.mcp.models import QueryResult
from app.mcp.scope import project_scope
from app.mcp.query_service import execute_query, task_service, authorize_query
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.domain.schemas.operational_data import TaskDataSnapshot
logger = logging.getLogger(__name__)
def tool_error(code, message):
return types.CallToolResult(
isError=True,
content=[types.TextContent(type="text", text=message)],
structuredContent={"error": {"code": code, "message": message}},
)
class ModernOnlyMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
if scope["method"] in {"GET", "DELETE"}:
return await JSONResponse(
{"error": "modern_protocol_required"},
status_code=405,
headers={"Allow": "POST"},
)(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
version = headers.get("mcp-protocol-version")
if version != "2026-07-28":
body = bytearray()
request_id = None
body_version = None
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body.extend(message.get("body", b""))
if len(body) > 1024 * 1024:
return await JSONResponse(
{"error": "request_too_large"}, status_code=413
)(scope, receive, send)
if not message.get("more_body"):
break
try:
payload = json.loads(body)
request_id = payload.get("id")
body_version = payload.get("params", {}).get("_meta", {}).get(
types.PROTOCOL_VERSION_META_KEY
)
except (ValueError, AttributeError):
pass
mismatch = not version or (
isinstance(body_version, str) and body_version != version
)
code = -32020 if mismatch else -32022
result = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": code,
"message": (
"MCP-Protocol-Version header is missing or differs from request metadata"
if mismatch else "MCP 2026-07-28 is required"
),
"data": {"supported": ["2026-07-28"], "requested": version},
},
}
return await JSONResponse(result, status_code=400)(scope, receive, send)
await self.app(scope, receive, send)
class McpQueryApplication:
def __init__(
self,
settings: McpSettings,
*,
verifier=None,
scope_factory=project_scope,
executor=execute_query,
):
self.settings = settings
self.verifier = verifier or McpTokenVerifier(settings)
self.scope_factory = scope_factory
self.executor = executor
self.active = {}
self.ready = False
self.server = Server(
"tjwater-query",
version="1.0.0",
instructions="Project-bound water queries. Model baselines are not observations. Truncated results do not cover the full range.",
cache_hints={
"server/discover": CacheHint(300000, "public"),
"tools/list": CacheHint(300000, "public"),
},
on_list_tools=self.list_tools,
on_call_tool=self.call_tool,
get_tool_input_schema=self.tool_input_schema,
on_list_resources=self.list_resources,
on_list_resource_templates=self.list_templates,
on_read_resource=self.read_resource,
)
self.server.add_request_handler(
"server/discover", types.RequestParams, self.discover
)
app = self.server.streamable_http_app(
stateless_http=True,
json_response=True,
max_request_body_size=1024 * 1024,
auth=AuthSettings(
issuer_url=settings.issuer_uri,
resource_server_url=settings.resource_uri,
required_scopes=["mcp.catalog.read"],
validate_token_resource=True,
),
token_verifier=self.verifier,
transport_security=TransportSecuritySettings(
allowed_hosts=settings.allowed_hosts,
allowed_origins=settings.allowed_origins,
),
)
# Catalog authentication and advertised resource scopes are different concerns.
metadata = create_protected_resource_routes(
resource_url=settings.resource_uri,
authorization_servers=[settings.issuer_uri],
scopes_supported=["mcp.catalog.read", QUERY_SCOPE, CONTEXT_SCOPE],
resource_name="TJWater Query",
)
metadata_path = metadata[0].path
app.router.routes = [
route for route in app.router.routes if route.path != metadata_path
] + metadata
self.asgi = ModernOnlyMiddleware(
QueryScopeMiddleware(app, self.verifier, settings)
)
from app.api.v1.endpoints.mcp_queries import router
self.router = router
@asynccontextmanager
async def lifespan(self):
async with self.server.session_manager.run():
self.ready = True
try:
yield
finally:
self.ready = False
async def discover(self, ctx, params):
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=self.server.get_capabilities(
protocol_version=ctx.protocol_version,
extensions={"io.modelcontextprotocol/oauth-client-credentials": {}},
),
instructions=self.server.instructions,
)
def tool_input_schema(self, name: str) -> dict[str, Any] | None:
tool = TOOLS.get(name)
if tool is None:
return None
schema = tool.model.model_json_schema()
if tool.computation:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
return schema
async def list_tools(self, ctx, params):
if params and params.cursor:
raise MCPError(-32602, "The query catalog has no continuation page")
tools = []
for name in sorted(TOOLS):
tool = TOOLS[name].protocol_tool()
tool.input_schema.update(self.tool_input_schema(name))
tools.append(tool)
return types.ListToolsResult(
tools=tools,
ttlMs=300000,
cacheScope="public",
)
async def list_resources(self, ctx, params):
return types.ListResourcesResult(resources=[], ttlMs=0, cacheScope="private")
async def list_templates(self, ctx, params):
return types.ListResourceTemplatesResult(
resourceTemplates=[
types.ResourceTemplate(
name="task-evidence",
uriTemplate="tjwater://tasks/{task_id}/evidence/{evidence_id}",
mimeType="application/json",
)
],
ttlMs=300000,
cacheScope="public",
)
async def read_resource(self, ctx, params):
meta = ctx.meta or {}
handle = meta.get("io.tjwater/contextHandle")
url = urlsplit(str(params.uri))
parts = url.path.strip("/").split("/")
if (
url.scheme != "tjwater"
or url.netloc != "tasks"
or len(parts) != 3
or parts[1] != "evidence"
or url.query
or url.fragment
):
raise MCPError(-32602, "Unknown query resource")
result = await self._query(
ctx,
"get_task_evidence",
{"context_handle": handle, "evidence_id": parts[2]},
resource_task=parts[0],
include_full=True,
)
if result.isError:
raise MCPError(
-32602, "Evidence is unavailable in this authorization context"
)
return types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=str(params.uri),
mimeType="application/json",
text=json.dumps(result.structuredContent, ensure_ascii=False),
)
],
ttlMs=0,
cacheScope="private",
)
async def call_tool(self, ctx, params):
return await self._query(ctx, params.name, params.arguments or {})
async def _query(
self, ctx, name, arguments, resource_task=None, include_full=False
):
tool = TOOLS.get(name)
if tool is None:
raise MCPError(-32602, "Unknown query tool")
principal = get_access_token()
if principal is None or QUERY_SCOPE not in principal.scopes:
return tool_error("PERMISSION_DENIED", "Query permission is required")
try:
args = tool.model.model_validate(arguments)
except ValidationError:
return tool_error(
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
timeout_seconds = (
args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds
)
if tool.computation and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
meta = ctx.meta or {}
call_id = meta.get("io.tjwater/callId")
if not isinstance(call_id, str) or not 0 < len(call_id) <= 128:
return tool_error(
"CALL_ID_REQUIRED", "A host-owned call identifier is required"
)
key = (principal.subject, project)
if self.active.get(key, 0) >= 4:
return tool_error(
"RATE_LIMITED", "At most four queries may run concurrently"
)
self.active[key] = self.active.get(key, 0) + 1
try:
async with (
asyncio.timeout(timeout_seconds),
self.scope_factory(principal, project) as scope,
):
binding = await resolve_context(scope, principal, args.context_handle)
if resource_task is not None and str(binding.task_id) != resource_task:
raise HTTPException(403, "Resource task mismatch")
await authorize_query(scope, binding, tool, args)
repo = McpQueryRepository(scope.connection, scope.project.project_id)
parameters = {
"tool": name,
"arguments": args.model_dump(
mode="json", exclude={"context_handle"}
),
}
scope_key = json.dumps(
[
binding.subject,
binding.client_id,
binding.native_session_id,
str(binding.task_id),
str(binding.run_id),
str(binding.execution_request_id),
],
separators=(",", ":"),
)
previous = (
await repo.receipt(scope_key, call_id) if binding.task_id else None
)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409, "Call identifier has different query parameters"
)
result = QueryResult.model_validate(previous["result"])
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
)
if include_full
else await self.executor(scope, binding, tool, args)
)
# Never serialize an unbounded response into a model message or database receipt.
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
return tool_error(
"RESULT_TOO_LARGE",
"Narrow the query or request a smaller page",
)
if binding.task_id and name not in {
"get_task_context",
"get_task_evidence",
}:
async with scope.connection.transaction():
await repo.lock_receipt(scope_key, call_id)
previous = await repo.receipt(scope_key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409,
"Call identifier has different query parameters",
)
result = QueryResult.model_validate(previous["result"])
else:
task_repo = task_service(scope).repo
# Lock the task row only after external/data-source work has finished.
await task_repo.lock(f"task:{binding.task_id}")
await verify_task(scope, binding)
result.source_ref = "mcp:" + str(
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
)
snapshot = TaskDataSnapshot(
source=name,
source_ref=result.source_ref,
queried_at=result.queried_at,
origin=result.origin,
data=result.model_dump(mode="json"),
truncated=result.truncated,
)
evidence, _ = await task_service(
scope
).record_data_snapshot(binding.task_id, snapshot)
result.evidence_id = evidence.evidence_id
await repo.save_receipt(
scope_key,
call_id,
parameters,
result.model_dump(mode="json"),
)
output = result.model_dump(mode="json")
content = []
if (
not include_full
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
):
evidence_id = result.evidence_id or (
args.evidence_id if name == "get_task_evidence" else None
)
if binding.task_id and evidence_id:
uri = (
f"tjwater://tasks/{binding.task_id}/evidence/{evidence_id}"
)
output["data"] = {
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
"Inline result abbreviated; read the saved evidence resource",
]
content.append(
types.ResourceLink(
type="resource_link",
uri=uri,
name="query-evidence",
mimeType="application/json",
)
)
else:
return tool_error(
"RESULT_TOO_LARGE",
"Request a smaller page or narrower window",
)
content.insert(
0,
types.TextContent(
type="text", text=json.dumps(output, ensure_ascii=False)
),
)
return types.CallToolResult(content=content, structuredContent=output)
except HTTPException as exc:
codes = {
401: "AUTH_REQUIRED",
403: "QUERY_CONTEXT_DENIED",
404: "NOT_FOUND",
409: "QUERY_CONTEXT_STALE",
413: "RESULT_TOO_LARGE",
422: "INVALID_QUERY",
503: "SOURCE_UNAVAILABLE",
}
return tool_error(
codes.get(exc.status_code, "QUERY_FAILED"),
str(exc.detail)
if isinstance(exc.detail, str)
and exc.status_code in {403, 404, 409, 413, 422}
else "Query is unavailable",
)
except (UndefinedTable, InvalidSchemaName):
return tool_error(
"MCP_STORAGE_UNAVAILABLE",
"MCP project storage has not been provisioned",
)
except TimeoutError:
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
except ValueError:
return tool_error(
"INVALID_QUERY", "Invalid query value or incompatible source data"
)
except Exception:
logger.warning(
"MCP query unavailable", extra={"tool": name, "project_id": project}
)
return tool_error("SOURCE_UNAVAILABLE", "Query source is unavailable")
finally:
remaining = self.active[key] - 1
if remaining:
self.active[key] = remaining
else:
self.active.pop(key, None)
async def identity(self, request):
auth = request.headers.get("authorization", "")
try:
principal = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except Exception:
raise HTTPException(503, "OAuth key verification is unavailable") from None
if not principal:
raise HTTPException(
401,
"MCP authentication required",
headers=challenge(self.settings, CONTEXT_SCOPE),
)
if CONTEXT_SCOPE not in principal.scopes:
raise HTTPException(
403,
"MCP context scope required",
headers=challenge(self.settings, CONTEXT_SCOPE, insufficient=True),
)
return principal
+169
View File
@@ -0,0 +1,169 @@
"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
import asyncio
import time
from uuid import UUID
import httpx
from jose import JWTError, jwt
from mcp.server.auth.provider import AccessToken
from starlette.responses import JSONResponse
from app.mcp.config import McpSettings
CATALOG_SCOPE = "mcp.catalog.read"
QUERY_SCOPE = "mcp.query"
CONTEXT_SCOPE = "mcp.context.bind"
class McpTokenVerifier:
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client
self._keys: list[dict] = []
self._expires = 0.0
self._last_refresh = 0.0
self._lock = asyncio.Lock()
async def _load_keys(self, kid: str) -> list[dict]:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
async with self._lock:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
if self._expires > now and now - self._last_refresh < 20:
return self._keys
url = (
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
)
self._last_refresh = now
if self.client:
response = await self.client.get(url)
else:
async with httpx.AsyncClient(
timeout=5, trust_env=False, follow_redirects=False
) as client:
response = await client.get(url)
response.raise_for_status()
try:
payload = response.json()
except ValueError:
raise httpx.RequestError("invalid OAuth key set response") from None
keys = payload.get("keys") if isinstance(payload, dict) else None
if not isinstance(keys, list) or len(keys) > 100:
raise httpx.RequestError("invalid OAuth key set")
self._keys = [
k
for k in keys
if isinstance(k, dict)
and k.get("kty") == "RSA"
and k.get("use", "sig") == "sig"
and k.get("alg", "RS256") == "RS256"
]
self._expires = now + 300
return self._keys
async def verify_token(self, token: str) -> AccessToken | None:
if not token or len(token) > 16384:
return None
try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if (
header.get("alg") != "RS256"
or not isinstance(kid, str)
or not 0 < len(kid) <= 128
):
return None
keys = await self._load_keys(kid)
key = next((k for k in keys if k.get("kid") == kid), None)
if key is None:
return None
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=self.settings.audience,
issuer=self.settings.issuer_uri,
options={"require_exp": True, "require_iat": True, "require_sub": True},
)
UUID(claims["sub"])
client_id = claims.get("azp")
if not isinstance(client_id, str) or not client_id:
return None
if (
not isinstance(claims.get("iat"), (int, float))
or not time.time() - 900 < claims["iat"] <= time.time() + 30
or claims["exp"] > claims["iat"] + 900
):
return None
return AccessToken(
token=token,
client_id=client_id,
subject=claims["sub"],
scopes=str(claims.get("scope", "")).split(),
expires_at=int(claims["exp"]),
resource=self.settings.resource_uri,
claims=claims,
)
except (JWTError, ValueError, KeyError, TypeError):
return None
# JWKS/network failures propagate as unavailable, never as empty data.
def challenge(
settings: McpSettings, scope: str, *, insufficient: bool = False
) -> dict[str, str]:
from mcp.server.auth.routes import build_resource_metadata_url
metadata = build_resource_metadata_url(settings.resource_uri)
suffix = ', error="insufficient_scope"' if insufficient else ""
return {
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
}
class QueryScopeMiddleware:
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
self.app, self.verifier, self.settings = app, verifier, settings
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
auth = headers.get("authorization", "")
try:
token = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except (httpx.HTTPError, ValueError):
return await JSONResponse(
{"error": "authentication_unavailable"}, status_code=503
)(scope, receive, send)
required = (
QUERY_SCOPE
if headers.get("mcp-method") in {"tools/call", "resources/read"}
else CATALOG_SCOPE
)
if token is None:
return await JSONResponse(
{"error": "invalid_token"},
status_code=401,
headers=challenge(self.settings, required),
)(scope, receive, send)
if required not in token.scopes:
return await JSONResponse(
{"error": "insufficient_scope"},
status_code=403,
headers=challenge(self.settings, required, insufficient=True),
)(scope, receive, send)
await self.app(scope, receive, send)
+221
View File
@@ -0,0 +1,221 @@
"""One finite query catalog; names, schemas and permissions come from this registry."""
from dataclasses import dataclass
from pydantic import BaseModel
from mcp import types
from app.mcp import models as m
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@dataclass(frozen=True)
class QueryTool:
name: str
description: str
model: type[BaseModel]
permission: str
semantics: str
task_only: bool = False
computation: bool = False
def protocol_tool(self) -> types.Tool:
if self.name == "run_simulation":
suffix = "。任务调用复用证据留存。"
elif self.computation:
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
elif self.name == "query_nearby_elements":
suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按需分页,不自动遍历全量;空间邻近不代表管网连通或供水关系。"
else:
suffix = (
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
"不要自动遍历全量;截断或非零offset不能证明完整范围无异常。"
)
return types.Tool(
name=self.name,
description=self.description + suffix,
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False
),
)
_specs = [
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
m.ElementInput,
"webgis.view",
"asset",
),
(
"list_elements",
"分页读取管网元素,不自动读取全网",
m.ElementPageInput,
"webgis.view",
"asset",
),
(
"query_nearby_elements",
"查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;不查询原始GIS管线、不自动扩大半径、不解析道路交叉点",
m.NearbyElementsInput,
"webgis.view",
"asset",
),
(
"get_network_options",
"读取模型时间、能源或网络选项",
m.OptionsInput,
"webgis.view",
"asset",
),
(
"get_network_schema",
"读取当前支持的元素、SCADA和选项结构",
m.SchemaInput,
"webgis.view",
"asset",
),
(
"list_scada_devices",
"分页读取SCADA设备和物理单位",
m.PageInput,
"scada.view",
"asset",
),
("get_scada_device", "读取一个SCADA设备", m.DeviceInput, "scada.view", "asset"),
(
"query_scada_readings",
"查询带时区且最多24小时的原始或清洗监测数据",
m.ScadaInput,
"scada.view",
"observed",
),
(
"query_model_baseline_series",
"读取最多20个元素的模型基线,不能称为实测",
m.BaselineSeriesInput,
"simulation.view",
"model_baseline",
),
(
"get_model_baseline_at_time",
"读取单个元素指定时刻的模型基线",
m.BaselineAtTimeInput,
"simulation.view",
"model_baseline",
),
(
"query_model_baseline_field",
"分页读取指定时刻的模型字段",
m.BaselineFieldInput,
"simulation.view",
"model_baseline",
),
(
"query_analysis_series",
"读取指定分析运行和元素字段时序",
m.AnalysisSeriesInput,
"simulation.view",
"analysis",
),
(
"query_analysis_values",
"分页读取指定分析运行某时刻的元素字段",
m.AnalysisValuesInput,
"simulation.view",
"analysis",
),
(
"list_analysis_runs",
"分页读取持久分析运行",
m.RunsInput,
"webgis.view",
"analysis",
),
("get_analysis_run", "读取单个持久分析运行", m.RunInput, "webgis.view", "analysis"),
(
"list_analysis_results",
"分页读取分析成果,可筛选result_type",
m.ResultsInput,
"webgis.view",
"analysis",
),
(
"list_sensor_placement_runs",
"分页读取已有监测点选址运行",
m.PageInput,
"optimization.run",
"analysis",
),
(
"get_sensor_placement_run",
"读取已有监测点选址成果",
m.RunInput,
"optimization.run",
"analysis",
),
(
"query_element_history",
"比较实测与模型基线或分析结果;analysis_comparison要求run_id;limit/offset按序列、时间排序的数据点分页",
m.HistoryInput,
"scada.view",
"observed",
),
(
"get_element_gis_properties",
"读取元素关联GIS台账",
m.ElementInput,
"webgis.view",
"asset",
),
(
"get_source_gis_properties",
"读取指定图层源要素台账",
m.SourceGisInput,
"webgis.view",
"asset",
),
(
"get_workbench_runtime",
"读取工作台已有运行态",
m.StrictInput,
"simulation.view",
"task",
),
(
"get_task_context",
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本",
m.TaskContextInput,
"operational_task.view",
"task",
True,
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
m.PeripheralInput,
"simulation.view",
"peripheral",
),
(
"get_task_evidence",
"读取当前绑定任务已保存的证据快照",
m.EvidenceInput,
"operational_task.view",
"task",
True,
),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+76
View File
@@ -0,0 +1,76 @@
"""MCP is opt-in; an incomplete OAuth resource must never start listening."""
from pathlib import Path
from ipaddress import IPv4Address, IPv4Network
from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class McpSettings(BaseSettings):
enabled: bool = False
issuer_uri: str = ""
dev_http_issuer_uri: str = ""
environment: str = Field(default="production", validation_alias="ENVIRONMENT")
resource_uri: str = ""
audience: str = "tjwater-mcp"
host_client_id: str = "tjwater-agent-mcp"
allowed_hosts: list[str] = Field(
default_factory=lambda: ["127.0.0.1:*", "localhost:*"]
)
allowed_origins: list[str] = Field(default_factory=list)
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
model_config = SettingsConfigDict(
env_prefix="TJWATER_MCP_",
extra="ignore",
populate_by_name=True,
env_file=Path(__file__).resolve().parents[2] / ".env",
)
@model_validator(mode="after")
def validate_resource(self):
if self.dev_http_issuer_uri:
dev_url = urlsplit(self.dev_http_issuer_uri)
try:
address = IPv4Address(dev_url.hostname or "")
private_ipv4 = any(address in IPv4Network(network) for network in (
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"
))
except ValueError:
private_ipv4 = False
if (
self.environment != "development"
or dev_url.scheme != "http"
or not private_ipv4
or dev_url.username or dev_url.password or dev_url.query or dev_url.fragment
or self.dev_http_issuer_uri != self.issuer_uri
):
raise ValueError("development HTTP issuer requires development environment and an exact private IPv4 issuer")
if not self.enabled:
return self
for index, value in enumerate((self.issuer_uri, self.resource_uri)):
url = urlsplit(value)
if (
url.scheme not in {"http", "https"}
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
):
raise ValueError("MCP requires explicit issuer and resource URIs")
if url.scheme == "http" and url.hostname not in {
"localhost",
"127.0.0.1",
"::1",
}:
if index != 0 or value != self.dev_http_issuer_uri:
raise ValueError("non-loopback MCP and OAuth require HTTPS")
if urlsplit(self.resource_uri).path != "/mcp":
raise ValueError("MCP resource URI must identify the /mcp endpoint")
if not self.audience.strip() or not self.host_client_id.strip():
raise ValueError("MCP audience and host client ID are required")
return self
+112
View File
@@ -0,0 +1,112 @@
"""An OAuth identity and a Server-owned binding are both required for data queries."""
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from app.auth.permissions import (
permissions_for_context,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_VIEW,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.mcp.models import BindInput, BoundContext
from app.mcp.scope import QueryScope
from app.mcp.config import McpSettings
from mcp.server.auth.provider import AccessToken
def task_repository(scope: QueryScope) -> OperationalTaskRepository:
return OperationalTaskRepository(scope.connection, scope.project.project_id)
async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
permissions = permissions_for_context(scope.project)
if OPERATIONAL_TASK_VIEW not in permissions:
raise HTTPException(403, "Task read permission is required")
repo = task_repository(scope)
async with scope.connection.transaction():
task = await repo.get_task(str(binding.task_id))
if not task:
raise HTTPException(404, "Task is not in this project")
if binding.purpose == "task_collaboration":
return
if OPERATIONAL_TASK_EXECUTE not in permissions:
raise HTTPException(403, "Task execution permission is required")
run = await repo.get_active_run(str(binding.task_id))
now = datetime.now(timezone.utc)
owner = binding.parent_session_id or binding.native_session_id
if (
not run
or run["status"] not in {"claimed", "running"}
or run.get("agent_session_id") != owner
or not run.get("lease_expires_at")
or run["lease_expires_at"] <= now
or binding.run_id is not None
and str(run["run_id"]) != str(binding.run_id)
or binding.execution_request_id is not None
and str(run["execution_request_id"]) != str(binding.execution_request_id)
):
raise HTTPException(
409, "Investigation run is no longer active for this session"
)
binding.run_id = UUID(str(run["run_id"]))
binding.execution_request_id = UUID(str(run["execution_request_id"]))
async def bind_context(
scope: QueryScope, principal: AccessToken, command: BindInput, settings: McpSettings
) -> BoundContext:
if principal.client_id != settings.host_client_id:
raise HTTPException(
403, "Only the trusted product host may bind query contexts"
)
from app.mcp.catalog import TOOLS
binding = BoundContext(
context_handle=secrets.token_urlsafe(32),
project_id=scope.project.project_id,
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items() if command.purpose != "task_reader" or not tool.computation),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
purpose=command.purpose,
parent_session_id=command.parent_session_id,
task_id=command.task_id,
expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=settings.context_ttl_seconds),
)
if binding.task_id:
await verify_task(scope, binding)
from app.infra.db.postgresql.read_queries import task_data_scopes
async with scope.connection.transaction():
binding.task_scope_cap = await task_data_scopes(scope.connection,
project_id=binding.project_id, task_id=binding.task_id,
permissions=permissions_for_context(scope.project))
await McpQueryRepository(scope.connection, scope.project.project_id).create(binding)
return binding
async def resolve_context(
scope: QueryScope, principal: AccessToken, handle: str
) -> BoundContext:
binding = await McpQueryRepository(scope.connection, scope.project.project_id).get(
handle
)
if (
not binding
or binding.revoked_at
or binding.expires_at <= datetime.now(timezone.utc)
or binding.subject != principal.subject
or binding.client_id != principal.client_id
or binding.project_id != scope.project.project_id
):
raise HTTPException(
403, "Query context is unavailable for this identity and project"
)
if binding.task_id:
await verify_task(scope, binding)
return binding
+15
View File
@@ -0,0 +1,15 @@
"""Delegate MCP transport/metadata with the original request path intact."""
class McpDispatchMiddleware:
def __init__(self, app, runtime):
self.app, self.runtime = app, runtime
async def __call__(self, scope, receive, send):
path = scope.get("path", "")
if scope["type"] == "http" and (
path == "/mcp" or path.startswith("/.well-known/oauth-protected-resource")
):
await self.runtime.asgi(scope, receive, send)
else:
await self.app(scope, receive, send)
+293
View File
@@ -0,0 +1,293 @@
"""Strict business inputs and provenance envelopes shared by MCP handlers."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")]
ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"]
SeriesType = Literal["junction", "pipe"]
class StrictInput(BaseModel):
model_config = ConfigDict(extra="forbid")
context_handle: Annotated[str, Field(min_length=32, max_length=128)]
class PageInput(StrictInput):
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class TaskContextInput(PageInput):
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
report_revision: int | None = Field(default=None, ge=1)
data_path: list[Id] = Field(default_factory=list, max_length=8)
@model_validator(mode="after")
def summary_is_fixed(self):
if self.section in {"summary", "skill"} and (self.data_path or self.offset):
raise ValueError("Select a detail section before using data_path or offset")
return self
class ElementInput(StrictInput):
element_type: ElementType
element_id: Id
class ElementPageInput(PageInput):
element_type: ElementType
class NearbyElementsInput(PageInput):
element_type: ElementType = "pipe"
center: tuple[
Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)],
Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)],
] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center")
radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False)
limit: int = Field(default=5, ge=1, le=100)
class OptionsInput(StrictInput):
kind: Literal["time", "energy", "pump-energy", "network"]
pump_id: Id | None = None
@model_validator(mode="after")
def pump_required(self):
if self.kind == "pump-energy" and not self.pump_id:
raise ValueError("pump_id is required for pump-energy")
if self.kind != "pump-energy" and self.pump_id:
raise ValueError("pump_id is only valid for pump-energy")
return self
class SchemaInput(StrictInput):
kind: Literal[
"junction",
"pipe",
"valve",
"pump",
"tank",
"reservoir",
"time",
"energy",
"pump-energy",
"option",
"scada-device",
]
class DeviceInput(StrictInput):
device_id: Id
class WindowInput(PageInput):
start: datetime
end: datetime
limit: int = Field(default=20, ge=1, le=10000)
@model_validator(mode="after")
def window_valid(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("time window must be positive and at most 24 hours")
return self
class ScadaInput(WindowInput):
device_ids: list[Id] = Field(min_length=1, max_length=20)
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
class BaselineSeriesInput(WindowInput):
element_type: SeriesType
element_ids: list[Id] = Field(min_length=1, max_length=20)
class TimeInput(StrictInput):
time: datetime
@model_validator(mode="after")
def timezone_required(self):
if self.time.tzinfo is None:
raise ValueError("timestamp requires explicit timezone")
return self
class BaselineAtTimeInput(TimeInput):
element_type: SeriesType
element_id: Id
class BaselineFieldInput(TimeInput):
element_type: SeriesType
field: Id
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class AnalysisSeriesInput(WindowInput):
run_id: UUID
element_type: SeriesType
element_id: Id
field: Id
class AnalysisValuesInput(BaselineFieldInput):
run_id: UUID
class RunInput(StrictInput):
run_id: UUID
class RunsInput(PageInput):
limit: int = Field(default=20, ge=1, le=100)
class ResultsInput(PageInput):
run_id: UUID
result_type: Id | None = None
class HistoryTarget(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: SeriesType
element_id: Id
device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20)
class HistoryInput(WindowInput):
elements: list[HistoryTarget] = Field(min_length=1, max_length=20)
mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed"
run_id: UUID | None = None
@model_validator(mode="after")
def analysis_required(self):
if (self.mode == "analysis_comparison") != (self.run_id is not None):
raise ValueError("run_id is required only for analysis_comparison")
return self
class SourceGisInput(StrictInput):
layer_key: Annotated[str, Field(min_length=1, max_length=80)]
source_fid: int = Field(ge=0, le=9223372036854775807)
class PeripheralInput(PageInput):
source: Literal[
"peripheral_points",
"peripheral_work_orders",
"peripheral_observations",
"peripheral_test_data",
]
point_id: Id | None = None
start: datetime | None = None
end: datetime | None = None
@model_validator(mode="after")
def observation_window(self):
if self.source == "peripheral_observations":
if not self.point_id or self.start is None or self.end is None:
raise ValueError("observations require point_id, start and end")
if (
self.start.tzinfo is None
or self.end.tzinfo is None
or not timedelta(0) < self.end - self.start <= timedelta(days=1)
):
raise ValueError(
"observation window requires timezone and at most 24 hours"
)
elif (
self.point_id is not None or self.start is not None or self.end is not None
):
raise ValueError(
"point_id and window are only valid for peripheral_observations"
)
return self
class EvidenceInput(PageInput):
evidence_id: Id
data_path: list[Id] = Field(default_factory=list, max_length=8)
class BindInput(BaseModel):
model_config = ConfigDict(extra="forbid")
native_session_id: Id
purpose: Literal[
"ordinary_chat", "task_execution", "task_reader", "task_collaboration"
]
task_id: UUID | None = None
parent_session_id: Id | None = None
@model_validator(mode="after")
def task_scope(self):
if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id):
raise ValueError("ordinary chat cannot bind a task")
if self.purpose != "ordinary_chat" and self.task_id is None:
raise ValueError("task context requires task_id")
if (self.purpose == "task_reader") != (self.parent_session_id is not None):
raise ValueError("only a task reader requires parent_session_id")
return self
class BindResponse(BaseModel):
context_handle: str
expires_at: datetime
task_id: UUID | None
run_id: UUID | None
class PageInfo(BaseModel):
limit: int
offset: int
total: int | None = None
has_more: bool
class QueryResult(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str
source_ref: str
queried_at: datetime
origin: Literal["system_data", "synthetic"] = "system_data"
semantics: Literal[
"asset",
"observed",
"model_baseline",
"analysis",
"comparison",
"task",
"peripheral",
]
data: JsonValue
page: PageInfo | None = None
window: dict[str, datetime] | None = None
units: dict[str, str] = Field(default_factory=dict)
truncated: bool = False
data_gaps: list[str] = Field(default_factory=list)
evidence_id: str | None = None
class BoundContext(BaseModel):
context_handle: str
project_id: UUID
subject: str
client_id: str
tool_cap: list[str] = Field(default_factory=list)
permission_cap: list[str] = Field(default_factory=list)
task_scope_cap: list[str] = Field(default_factory=list)
native_session_id: str
purpose: str
parent_session_id: str | None = None
task_id: UUID | None = None
run_id: UUID | None = None
execution_request_id: UUID | None = None
expires_at: datetime
revoked_at: datetime | None = None
+132
View File
@@ -0,0 +1,132 @@
"""Read existing project-owned peripheral facts without forwarding MCP tokens."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, scada_devices, series_rows
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
from app.services.content_digest import digest
from samples.peripheral.store import PostgresStore
from app.infra.integrations.operational_data import _select_demo_observations
async def read_peripheral_query(scope, args):
conn = scope.connection
project = str(scope.project.project_id)
truncated = False
origin = "system_data"
if args.source in {"peripheral_work_orders", "peripheral_test_data"}:
table, column = (
("work_orders", "work_order_id")
if args.source == "peripheral_work_orders"
else ("test_data_records", "record_id")
)
rows = await read_rows(
conn,
"SELECT document FROM peripheral_sample."
+ table
+ " WHERE project_id=%s AND deleted_at IS NULL ORDER BY "
+ column
+ " LIMIT %s OFFSET %s",
(project, args.limit + 1, args.offset),
)
items = [
PostgresStore._active_managed_document(row["document"])
for row in rows[: args.limit]
]
truncated = len(rows) > args.limit
if any(item.get("project_id") != project for item in items):
raise HTTPException(502, "Peripheral project mismatch")
data = {
"items": items,
"truncated": truncated,
"limit": args.limit,
"offset": args.offset,
}
origin = (
"synthetic"
if items and any(item.get("origin") == "synthetic" for item in items)
else "system_data"
)
elif args.source == "peripheral_points":
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
data = {
"origin": "historical_replay",
"items": [
{
"point_id": row["device_id"],
"label": row["device_id"] + " · " + row["device_type"],
"unit": row["measurement_unit"],
"status": "unknown",
"origin": "historical_replay",
}
for row in rows[: args.limit]
],
"truncated": len(rows) > args.limit,
}
truncated = data["truncated"]
elif args.point_id.startswith("DEMO-"):
rows = await read_rows(
conn,
"SELECT CASE WHEN octet_length(document::text)<=%s THEN document END AS document,"
"digest,octet_length(document::text)>%s AS too_large "
"FROM peripheral_sample.demo_datasets WHERE project_id=%s AND dataset_id='water-demo-v1'",
(MAX_QUERY_BYTES, MAX_QUERY_BYTES, project),
)
if rows and rows[0]["too_large"]:
raise HTTPException(413, "Synthetic dataset exceeds the integrity-check byte budget")
if not rows or digest(rows[0]["document"]) != rows[0]["digest"]:
raise HTTPException(503, "Synthetic dataset is unavailable or invalid")
query = TaskDataQuery(
source=args.source, point_id=args.point_id, start=args.start, end=args.end
)
data = _select_demo_observations(
{**rows[0]["document"], "project_id": project}, query, project
)
items = data["items"]
truncated = len(items) > args.limit + args.offset
data["items"] = items[args.offset : args.offset + args.limit]
data["truncated"] = truncated
origin = "synthetic"
else:
devices = await scada_devices(conn, device_id=args.point_id, limit=1)
if not devices:
raise HTTPException(404, "Peripheral point was not found")
device = devices[0]
if not device.get("api_query_id"):
raise HTTPException(503, "Peripheral history mapping is unavailable")
async with scope.timescale() as ts:
rows = await series_rows(
ts,
source="scada",
element_type="junction",
ids=[device["api_query_id"]],
start=args.start,
end=args.end,
field="monitored_value",
limit=args.limit + 1,
offset=args.offset,
end_exclusive=True,
)
truncated = len(rows) > args.limit
data = {
"origin": "historical_replay",
"items": [
{
"point_id": args.point_id,
"observed_at": row["time"].isoformat(),
"value": row["value"],
"unit": device["measurement_unit"],
}
for row in rows[: args.limit]
],
"truncated": truncated,
}
return TaskDataSnapshot(
source=args.source,
source_ref="mcp:" + str(uuid4()),
queried_at=datetime.now(timezone.utc),
data=data,
origin=origin,
truncated=truncated,
)
+492
View File
@@ -0,0 +1,492 @@
"""MCP domain dispatch reuses Server repositories and services, never an HTTP loopback."""
import asyncio
import math
from contextlib import nullcontext
from typing import Any
from pydantic import BaseModel
from app.mcp.scope import QueryScope
from app.mcp.catalog import QueryTool, TOOLS
from app.mcp.models import BoundContext
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
from app.mcp.models import QueryResult, PageInfo
from app.mcp.scenario_service import SCENARIO_TOOLS
from app.mcp.work_order_skills import read_work_order_skill
def finite_data(value: Any) -> tuple[Any, int]:
"""JSON cannot represent infinity/NaN; preserve the gap as null with provenance."""
if isinstance(value, float) and not math.isfinite(value):
return None, 1
count = 0
if isinstance(value, dict):
result = {}
for key, item in value.items():
result[key], replaced = finite_data(item)
count += replaced
return result, count
if isinstance(value, list):
result = []
for item in value:
clean, replaced = finite_data(item)
result.append(clean)
count += replaced
return result, count
return value, 0
def task_service(scope: QueryScope) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project,
set(permissions_for_context(scope.project)),
)
def page_result(rows, args):
has_more = len(rows) > args.limit
return (
rows[: args.limit],
PageInfo(limit=args.limit, offset=args.offset, total=None, has_more=has_more),
has_more,
)
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
permissions = permissions_for_context(scope.project)
if tool.computation:
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
raise HTTPException(403, "Sensor placement requires project member access")
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot start calculations")
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Calculation and input data permissions are required")
permission = (
"scada.view"
if tool.name == "get_network_schema" and args.kind == "scada-device"
else tool.permission
)
if tool.task_only and binding.task_id is None:
raise HTTPException(403, "This query requires a bound task")
granted = permission in permissions and permission in binding.permission_cap
source = {
"list_scada_devices": "scada_devices",
"get_scada_device": "scada_devices",
"query_scada_readings": "scada_readings",
"query_element_history": "scada_readings",
"get_workbench_runtime": "workbench_runtime",
}.get(tool.name)
if tool.name == "query_peripheral_data":
source = args.source
if not granted and binding.task_id and source in binding.task_scope_cap:
async with scope.connection.transaction():
granted = await task_service(scope).can_read_source(binding.task_id, source)
if not granted:
raise HTTPException(403, "Data source permission is required")
if (
tool.name == "query_element_history"
and args.mode != "observed"
and (
"simulation.view" not in permissions
or "simulation.view" not in binding.permission_cap
)
):
raise HTTPException(403, "Model comparison permission is required")
async def execute_query(
scope: QueryScope,
binding: BoundContext,
tool: QueryTool,
args: BaseModel,
*,
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
name = tool.name
conn = scope.connection
page = None
truncated = False
units = {}
origin = "system_data"
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
if tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
# it before potentially long CPU/IO work; evidence uses a new transaction.
await conn.commit()
async with (
scope.simulation_routing()
if name in TIMESCALE_CALCULATIONS
else nullcontext()
):
data = await calculate(
name, args, network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": data,
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
"optimize_sensor_placement": "Candidate pressure monitoring locations, not a saved deployment scheme or a guarantee of contaminant source observability.",
"run_leakage_identification": "DMA leakage estimates depend on the total leakage constraint and aligned model/SCADA time; they do not confirm an exact leak location.",
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
}[name]]
if getattr(args, "data_source", None) == "simulation":
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
elif name == "query_nearby_elements":
rows = await nearby_elements(
conn, args.element_type, center=args.center, radius_m=args.radius_m,
limit=args.limit + 1, offset=args.offset,
)
items, page, truncated = page_result(rows, args)
truncated = truncated or args.offset > 0
data = {
"source": "model_network", "element_type": args.element_type,
"center": list(args.center), "center_crs": "EPSG:4326",
"radius_m": args.radius_m, "distance_method": "WGS84_spheroid",
"items": items,
}
units = {"distance_m": "m", "radius_m": "m"}
gaps = ["Spatial proximity does not establish hydraulic connectivity or a supply relationship"]
elif name in {"get_element_properties", "list_elements"}:
rows = await reads.asset_rows(
conn,
args.element_type,
element_id=getattr(args, "element_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_element_properties":
if not rows:
raise HTTPException(404, "Element was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name in {"list_scada_devices", "get_scada_device"}:
rows = await reads.scada_devices(
conn,
device_id=getattr(args, "device_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_scada_device":
if not rows:
raise HTTPException(404, "SCADA device was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "get_network_schema":
from app.services import tjnetwork
mapping = {
"option": "get_option_v3_schema",
"scada-device": "get_scada_info_schema",
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
if args.kind == "pump-energy":
rows = await reads.read_rows(
conn,
"SELECT price,pattern_id AS pattern,efficiency_curve_id AS effic FROM network.pump_energy_settings WHERE pump_id=%s",
(args.pump_id,),
)
data = {"pump": args.pump_id, "price": None, "pattern": None, "effic": None}
if rows:
data.update(rows[0])
else:
table = {
"time": "time_settings",
"energy": "energy_settings",
"network": "simulation_settings",
}[args.kind]
where = " WHERE engine_version='v3'" if args.kind == "network" else ""
rows = await reads.read_rows(
conn,
"SELECT key,value FROM network."
+ table
+ where
+ " ORDER BY key LIMIT 501",
)
data = {str(row["key"]): str(row["value"]) for row in rows[:500]}
truncated = len(rows) > 500
elif name in {
"query_scada_readings",
"query_model_baseline_series",
"get_model_baseline_at_time",
"query_model_baseline_field",
"query_analysis_series",
"query_analysis_values",
}:
source = (
"scada"
if name == "query_scada_readings"
else "analysis"
if name.startswith("query_analysis")
else "realtime"
)
ids = (
getattr(args, "device_ids", None)
or getattr(args, "element_ids", None)
or ([args.element_id] if hasattr(args, "element_id") else [])
)
async with scope.timescale() as ts:
rows = await reads.series_rows(
ts,
source=source,
element_type=getattr(args, "element_type", "junction"),
ids=ids,
start=getattr(args, "start", None),
end=getattr(args, "end", None),
time=getattr(args, "time", None),
field=getattr(args, "field", None),
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_model_baseline_at_time":
data = rows[:1]
else:
data, page, truncated = page_result(rows, args)
if source != "scada":
async with conn.transaction():
units = await NetworkSettingsRepository.get_result_units(conn)
if source == "analysis":
run = await AnalysisRepository.get_run(conn, args.run_id)
if not run:
raise HTTPException(404, "Analysis run was not found")
recorded = (run.get("parameters") or {}).get("result_units")
if isinstance(recorded, dict):
units = recorded
else:
gaps.append(
"Analysis units inferred from current network settings"
)
if hasattr(args, "field") and args.field not in units:
gaps.append(f"Unit metadata is unavailable for field {args.field}")
else:
metadata = [
r
for device in ids
for r in await reads.scada_devices(conn, device_id=device, limit=1)
]
units = {
r["device_id"]: r["measurement_unit"]
for r in metadata
if r["device_id"] in ids
}
elif name in {"list_analysis_runs", "get_analysis_run", "list_analysis_results"}:
async with conn.transaction():
if name == "get_analysis_run":
data = await AnalysisRepository.get_run(conn, args.run_id)
if not data:
raise HTTPException(404, "Analysis run was not found")
elif name == "list_analysis_runs":
# Fetch one additional row with the repository's supported summary-page bound.
rows = await AnalysisRepository.list_run_summaries(
conn, limit=args.limit + 1, offset=args.offset
)
data, page, truncated = page_result(rows, args)
else:
rows = await AnalysisRepository.list_results(
conn,
args.run_id,
args.result_type,
limit=args.limit,
lookahead=True,
offset=args.offset,
)
data, page, truncated = page_result(rows, args)
elif name in {"list_sensor_placement_runs", "get_sensor_placement_run"}:
rows = await reads.sensor_runs(
conn,
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_sensor_placement_run":
if not rows:
raise HTTPException(404, "Sensor placement run was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "query_element_history":
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.services.timeseries_history import TimeseriesHistoryService
query = ElementHistoryQuery(
start_time=args.start,
end_time=args.end,
mode=args.mode,
run_id=args.run_id,
elements=[e.model_dump() for e in args.elements],
)
async with scope.timescale() as ts, conn.transaction(), ts.transaction():
result = await TimeseriesHistoryService.query(
ts, conn, query, row_limit=args.limit, row_offset=args.offset
)
data = result.model_dump(mode="json")
truncated = result.truncated
page = PageInfo(limit=args.limit, offset=args.offset, has_more=truncated)
truncated = truncated or args.offset > 0
elif name in {"get_element_gis_properties", "get_source_gis_properties"}:
async with conn.transaction():
if name == "get_element_gis_properties":
data = await GisAttributeRepository.list_element_groups(
conn, args.element_type, args.element_id
)
else:
data = await GisAttributeRepository.get_source_feature_group(
conn, args.layer_key, args.source_fid
)
if data is None:
raise HTTPException(404, "Source GIS feature was not found")
elif name == "get_task_context":
if args.section in {"summary", "skill"}:
data = await reads.task_context_summary(conn, project_id=binding.project_id,
task_id=binding.task_id, report_revision=args.report_revision)
skill = read_work_order_skill(
data.get("task", {}).get("workflow"), include_content=args.section == "skill"
)
if args.section == "skill":
data = {"section": "skill", "skill": skill}
else:
data["handling_skill"] = skill
data["display_time_zone"] = "Asia/Shanghai"
data["approved_data_scopes"] = binding.task_scope_cap
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
else:
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
total = projection["total"]
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
total = projection["snapshot_metadata"].get("total", total)
if projection["value_type"] in {"array", "object", "string"}:
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
elif args.offset:
raise HTTPException(422, "Scalar task detail cannot use an offset")
truncated = bool(projection.get("nested") or args.offset or page and page.has_more)
data = {"section": args.section, "selected_path": args.data_path, "value": projection["value"], "metadata": projection["snapshot_metadata"]}
if projection.get("nested"):
gaps.append("Nested values have data_path descriptors; select a descriptor path for its values")
elif name == "get_task_evidence":
projection = await reads.evidence_page(
conn, project_id=scope.project.project_id, task_id=binding.task_id,
evidence_id=args.evidence_id, data_path=args.data_path,
limit=args.limit, offset=args.offset, mcp_sources=list(TOOLS),
full_snapshot=full_snapshot,
)
document = projection if full_snapshot else projection["snapshot_metadata"]
origin = document["origin"]
source_ref = document["source_ref"]
evidence_id = args.evidence_id
truncated = document.get("truncated", False)
if full_snapshot:
data = document
else:
selected = projection["value"]
if projection["value_type"] in {"array", "object", "string"}:
total = projection["total"]
page = PageInfo(
limit=args.limit,
offset=args.offset,
total=total,
has_more=args.offset + args.limit < total,
)
truncated = truncated or page.has_more or args.offset > 0
elif args.offset:
raise HTTPException(422, "Scalar evidence cannot use an offset")
if projection.get("nested"):
truncated = True
gaps.append(
"Nested collections have data_path descriptors; query a descriptor path for their values"
)
data = {
"snapshot_metadata": document,
"query_metadata": projection["query_metadata"],
"selected_path": args.data_path,
"value": selected,
}
elif name == "get_workbench_runtime":
from app.services.workbench_runtime import read_workbench_runtime
data = (await read_workbench_runtime(scope.project, conn)).model_dump(
mode="json"
)
elif name == "query_peripheral_data":
from app.mcp.peripheral import read_peripheral_query
snapshot = await read_peripheral_query(scope, args)
data = snapshot.data
origin = snapshot.origin
truncated = snapshot.truncated
else:
raise ValueError("query handler is not registered")
window = (
{"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None
else None
)
data, nonfinite = finite_data(jsonable_encoder(data))
semantics = (
"comparison"
if name == "query_element_history" and args.mode != "observed"
else tool.semantics
)
return QueryResult(
source=name,
source_ref=source_ref,
evidence_id=evidence_id,
queried_at=datetime.now(timezone.utc),
semantics=semantics,
data=data,
data_gaps=gaps
+ (
[f"{nonfinite} non-finite numeric values were returned as null"]
if nonfinite
else []
),
page=page,
window=window,
units=units,
truncated=truncated,
origin=origin,
)
+70
View File
@@ -0,0 +1,70 @@
"""MCP online recalculation and isolated scenario adapters."""
from typing import Any
SCENARIO_TOOLS = frozenset({
"run_simulation",
"run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation",
})
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
parameters = dict(parameters)
nodes = parameters.pop("output_node_ids")
links = parameters.pop("output_link_ids")
start = parameters.pop("start_time")
duration = parameters.pop("duration")
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None)
if name == "run_simulation":
from app.services.simulation import run_online_simulation
result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_contaminant_simulation":
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
elif name == "analyze_flushing":
# The service consumes m³/h (the older REST label incorrectly says L/s).
# Reject unsupported conversion rather than silently ignore the flow.
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
raise ValueError("Pump controls must reference registered model pumps")
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
raise ValueError("Fixed-speed pump controls must be 0 or 1")
result = scenarios.pressure_regulation(**common,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
else:
raise ValueError("Scenario is not registered")
if not isinstance(result, dict):
raise ValueError("Scenario did not return calculation results")
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
return {
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
"start_time": start.isoformat(), "duration_seconds": duration,
"units": result.get("units", {}), "times": result.get("times", {}),
"node_results": selected_nodes, "link_results": selected_links,
"coverage": {"requested_nodes": nodes, "requested_links": links,
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
}
+76
View File
@@ -0,0 +1,76 @@
"""Resolve authoritative metadata and project routing for each MCP request."""
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, AsyncIterator
from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext
from app.infra.db.project_routing import ActiveProjectRouting
from fastapi import HTTPException
from mcp.server.auth.provider import AccessToken
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import (
resolve_project_context,
resolve_project_business_routing,
resolve_project_simulation_routing,
get_project_pg_connection,
get_project_timescale_connection,
)
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import activate_project_routing
class QueryMetadataRepository(MetadataRepository):
async def get_project_db_routing(self, project_id, role):
routing = await super().get_project_db_routing(project_id, role)
# Existing connection generators obtain routing before awaiting a pool.
# End that implicit SELECT transaction here, before the network wait.
await self.session.commit()
return routing
@dataclass
class QueryScope:
project: ProjectContext
user: Any
connection: AsyncConnection
routing: ActiveProjectRouting
metadata: QueryMetadataRepository
@asynccontextmanager
async def simulation_routing(self) -> AsyncIterator[ActiveProjectRouting]:
"""Resolve both databases before a native calculation can modify its model."""
routing = await resolve_project_simulation_routing(self.project, self.metadata)
await self.metadata.session.commit()
with activate_project_routing(routing):
yield routing
@asynccontextmanager
async def timescale(self):
# Metadata repository here is used only to resolve a known project DB.
async with asynccontextmanager(get_project_timescale_connection)(
self.project, self.metadata
) as conn:
await self.metadata.session.commit()
yield conn
@asynccontextmanager
async def project_scope(principal: AccessToken, project_id: str):
if not principal.claims:
raise HTTPException(401, "MCP identity is missing")
async with asynccontextmanager(get_metadata_session)() as session:
metadata = QueryMetadataRepository(session)
user = await get_current_metadata_user(principal.claims, metadata)
project = await resolve_project_context(project_id, user, metadata)
routing = await resolve_project_business_routing(project, metadata)
# End the metadata read before any external query/IO waits.
await session.commit()
async with asynccontextmanager(get_project_pg_connection)(
project, metadata
) as conn:
await session.commit()
with activate_project_routing(routing):
yield QueryScope(project, user, conn, routing, metadata)
+40
View File
@@ -0,0 +1,40 @@
"""Server-owned business instructions, selected from the persisted task workflow."""
from hashlib import sha256
from pathlib import Path
from typing import Any
import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
"burst_report": "burst-report",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",
}
def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]:
"""Read only packaged, allowlisted instructions; never interpret a task path."""
name = WORKFLOW_SKILLS.get(workflow)
if name is None:
return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"}
content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8")
_, frontmatter, body = content.split("---", 2)
header = yaml.safe_load(frontmatter)
if header["name"] != name:
raise ValueError("Packaged work-order skill name does not match its directory")
result = {
"available": True,
"kind": "business_instructions",
"workflow": workflow,
"name": header["name"],
"description": header["description"],
"version": str(header["metadata"]["version"]),
"content_sha256": sha256(content.encode("utf-8")).hexdigest(),
"read": {"section": "skill"},
}
if include_content:
result["instructions"] = body.strip()
return result