261 lines
8.9 KiB
Python
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)
|