Files
wyndham-ARR/arr_mcp/server.py
2026-07-29 16:38:05 +08:00

261 lines
8.9 KiB
Python

"""Official MCP SDK binding for the single ARR direct-ingestion tool."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Dict, Mapping, Tuple
from arr_ingestion.contracts import IngestionError
from arr_mcp.auth import BearerAuthASGI, BearerAuthConfig
from arr_mcp.gateway import DirectResultGateway
TOOL_NAME = "arr_submit_processing_result"
SERVER_VERSION = "1.0.1"
MAX_MCP_REQUEST_BYTES = 4 * 1024 * 1024
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REQUEST_SCHEMA = (
PROJECT_ROOT
/ "database"
/ "contracts"
/ "arr-submit-processing-result-v1.schema.json"
)
RECEIPT_SCHEMA = (
PROJECT_ROOT
/ "database"
/ "contracts"
/ "arr-submit-processing-result-receipt-v1.schema.json"
)
STRUCTURED_SCHEMA = (
PROJECT_ROOT
/ "arr-opera-daily-ingest"
/ "references"
/ "structured-result.schema.json"
)
LOGGER = logging.getLogger(__name__)
def _json_object(path: Path) -> Dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("ARR MCP schema is invalid")
return value
def _advertised_payload_schema(payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Project the strict payload contract into an LLM-tool-safe Schema.
The authoritative payload validation stays in ``DirectSubmissionRequest``
and the independent source replay. Tool discovery only needs a compact
shape that tells the Agent to pass every top-level structured-result field.
Keeping local ``$ref`` values inside a schema nested below ``payload`` is
not portable: some MCP-to-LLM adapters resolve ``#`` from the outer request
document and reject the tool before the model can run.
"""
required = payload.get("required")
properties = payload.get("properties")
definitions = payload.get("$defs")
if (
not isinstance(required, list)
or not all(isinstance(value, str) and value for value in required)
or not isinstance(properties, Mapping)
or not isinstance(definitions, Mapping)
):
raise ValueError("ARR MCP structured payload schema is invalid")
required_set = set(required)
if required_set != set(properties):
raise ValueError("ARR MCP structured payload fields are invalid")
sha256 = definitions.get("sha256")
channel = definitions.get("channel")
if not isinstance(sha256, Mapping) or not isinstance(channel, Mapping):
raise ValueError("ARR MCP structured payload definitions are invalid")
count_names = {
"source_rows",
"removed_by_rate_code",
"removed_as_duplicates",
"output_rows",
}
advertised_properties: Dict[str, Any] = {
"result_schema_version": {"type": "string", "const": "3.0"},
"status": {"type": "string", "const": "success"},
"activation_eligible": {"type": "boolean", "const": True},
"ingestion_mode": {"type": "string", "const": "opera_xml"},
"business_date": {"type": "string", "format": "date"},
"processor_version": {"type": "string", "minLength": 1},
"rule_set_sha256": dict(sha256),
"outcome_counts": {
"type": "object",
"description": "Complete unmodified outcome_counts object.",
},
"channels": {
"type": "array",
"items": dict(channel),
"description": "Complete unmodified channels array.",
},
"artifacts": {
"type": "object",
"description": "Complete unmodified artifacts object.",
},
"records": {
"type": "array",
"items": {"type": "object"},
"description": "Every unmodified structured-result record.",
},
"errors": {
"type": "array",
"items": {"type": "object"},
"maxItems": 0,
},
}
for name in count_names:
advertised_properties[name] = {"type": "integer", "minimum": 0}
if set(advertised_properties) != required_set:
raise ValueError("ARR MCP advertised payload fields are invalid")
return {
"type": "object",
"description": (
"The complete, unmodified successful structured-result.json "
"object. ARR performs the full contract validation and source replay."
),
"additionalProperties": False,
"required": list(required),
"properties": advertised_properties,
}
def load_tool_schemas() -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Return a portable advertised request schema and strict receipt."""
request = _json_object(REQUEST_SCHEMA)
receipt = _json_object(RECEIPT_SCHEMA)
payload = _json_object(STRUCTURED_SCHEMA)
try:
payload_contract = request["properties"]["payload"]["allOf"]
reference = payload_contract[0]["$ref"]
except (KeyError, IndexError, TypeError):
raise ValueError("ARR MCP request schema is invalid") from None
expected = (REQUEST_SCHEMA.parent / reference).resolve()
if expected != STRUCTURED_SCHEMA.resolve():
raise ValueError("ARR MCP request payload schema is invalid")
request["properties"]["payload"] = _advertised_payload_schema(payload)
request.pop("$schema", None)
request.pop("$id", None)
return request, receipt
def _json_text(value: Mapping[str, Any]) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def create_mcp_server(gateway: DirectResultGateway) -> Any:
"""Create the low-level server so the advertised Schema stays exact."""
import anyio
import mcp.types as types
from mcp.server import Server
request_schema, receipt_schema = load_tool_schemas()
tool = types.Tool(
name=TOOL_NAME,
title="Submit ARR processing result",
description=(
"Submit the exact frozen successful ARR structured-result payload "
"once. ARR independently replays the registered source XML before "
"atomically activating Finance facts."
),
inputSchema=request_schema,
outputSchema=receipt_schema,
)
async def list_tools(_context: Any, _params: Any) -> Any:
return types.ListToolsResult(tools=[tool])
async def call_tool(_context: Any, params: Any) -> Any:
if params.name != TOOL_NAME:
error = {
"code": "MCP_TOOL_NOT_FOUND",
"message": "requested ARR MCP tool is unavailable",
"retryable": False,
}
return types.CallToolResult(
content=[types.TextContent(text=_json_text(error))],
isError=True,
)
try:
result = await anyio.to_thread.run_sync(
gateway.submit_processing_result,
params.arguments,
)
except IngestionError as error:
payload = {
"code": error.code,
"message": error.safe_message,
"retryable": error.retryable,
}
return types.CallToolResult(
content=[types.TextContent(text=_json_text(payload))],
isError=True,
)
except Exception as error:
LOGGER.error(
"ARR MCP tool failed with unexpected %s",
type(error).__name__,
)
payload = {
"code": "DIRECT_GATEWAY_INTERNAL",
"message": "direct result gateway failed safely",
"retryable": True,
}
return types.CallToolResult(
content=[types.TextContent(text=_json_text(payload))],
isError=True,
)
return types.CallToolResult(
content=[types.TextContent(text=_json_text(result))],
structuredContent=result,
)
return Server(
"arr-ingestion-gateway",
version=SERVER_VERSION,
title="ARR ingestion gateway",
description="Attempt-bound validated ARR daily-result ingestion",
on_list_tools=list_tools,
on_call_tool=call_tool,
)
def create_http_application(
gateway: DirectResultGateway,
bearer: BearerAuthConfig,
*,
allowed_hosts: list[str],
) -> Any:
if not allowed_hosts or any(not value for value in allowed_hosts):
raise ValueError("ARR MCP allowed hosts are required")
from mcp.server.transport_security import TransportSecuritySettings
server = create_mcp_server(gateway)
application = server.streamable_http_app(
json_response=True,
stateless_http=True,
max_request_body_size=MAX_MCP_REQUEST_BYTES,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=list(allowed_hosts),
allowed_origins=[],
),
)
return BearerAuthASGI(application, bearer)