feat: prepare ARR for controlled public deployment
This commit is contained in:
260
arr_mcp/server.py
Normal file
260
arr_mcp/server.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user