feat: prepare ARR for controlled public deployment
This commit is contained in:
70
arr_mcp/README.md
Normal file
70
arr_mcp/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ARR MCP ingestion gateway
|
||||
|
||||
This is a standalone Streamable HTTP MCP server with exactly one tool:
|
||||
`arr_submit_processing_result`.
|
||||
|
||||
The protocol process is intentionally thin. It authenticates the HTTP request,
|
||||
advertises the frozen four-field tool Schema, bounds concurrent processor
|
||||
replays, and calls `DirectSubmissionService`. It does not contain SQL or trust
|
||||
Agent-provided Finance facts.
|
||||
|
||||
## Runtime
|
||||
|
||||
The official MCP Python SDK 2.0 requires Python 3.10+. Keep it separate from the
|
||||
portal's historical Python 3.9 virtual environment:
|
||||
|
||||
```bash
|
||||
python3.12 -m venv .venv-mcp
|
||||
.venv-mcp/bin/python -m pip install -r requirements-arr-mcp.txt
|
||||
```
|
||||
|
||||
The server uses the recommended stateless Streamable HTTP transport with JSON
|
||||
responses, a 4 MiB HTTP-body limit, DNS-rebinding Host allowlisting, and a
|
||||
required bearer token. See the
|
||||
[official Python SDK](https://github.com/modelcontextprotocol/python-sdk) and
|
||||
[low-level server guidance](https://py.sdk.modelcontextprotocol.io/v2/advanced/low-level-server/).
|
||||
|
||||
Local launch with Keychain-backed secrets:
|
||||
|
||||
```bash
|
||||
.venv-mcp/bin/python -m arr_mcp.launch \
|
||||
--db-config /path/to/private/booking-test-db.env \
|
||||
--host 127.0.0.1 \
|
||||
--port 8890
|
||||
```
|
||||
|
||||
The launcher reads only OSS routing values from the owned `0600` route file.
|
||||
It loads OSS credentials and `ARR_MCP_BEARER_TOKEN` from macOS Keychain account
|
||||
`arr-web`; no secret belongs in this repository, command line, Main Prompt, or
|
||||
Agent output.
|
||||
|
||||
When binding beyond loopback, pass every accepted reverse-proxy Host header:
|
||||
|
||||
```bash
|
||||
--host 0.0.0.0 --allowed-host mcp.example.com
|
||||
```
|
||||
|
||||
Do not expose plain HTTP publicly. Terminate HTTPS at a controlled reverse proxy
|
||||
or tunnel and forward only the MCP endpoint.
|
||||
|
||||
## SuperAgent MCP entry
|
||||
|
||||
- Service identifier: `arr_ingestion_gateway`
|
||||
- Display name: `ARR Ingestion Gateway`
|
||||
- Transport: `HTTP`
|
||||
- Service address: `https://<controlled-host>/mcp`
|
||||
- Status: activate only after the no-PII acceptance test passes
|
||||
- Headers: `Authorization: Bearer <ARR_MCP_BEARER_TOKEN>` supplied through the
|
||||
platform's secret facility
|
||||
- OAuth JSON: `{}` unless the deployment later adopts a dedicated OAuth server
|
||||
|
||||
The bearer protects the transport. The short-lived `submission_grant` inside
|
||||
the tool call separately binds the write to one registered job attempt. Both
|
||||
must be valid.
|
||||
|
||||
## Success and error behavior
|
||||
|
||||
Success returns only the strict receipt: `committed` or `already_committed`,
|
||||
job/attempt, business date, daily version identity and record count. A business
|
||||
or validation error is an MCP tool error containing only a stable code, safe
|
||||
message and `retryable` flag. Request payloads and grants are never logged.
|
||||
5
arr_mcp/__init__.py
Normal file
5
arr_mcp/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Thin MCP transport boundary for ARR direct result ingestion."""
|
||||
|
||||
from arr_mcp.gateway import DirectResultGateway
|
||||
|
||||
__all__ = ["DirectResultGateway"]
|
||||
93
arr_mcp/auth.py
Normal file
93
arr_mcp/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Transport-level bearer protection for the ARR MCP ASGI application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Tuple
|
||||
|
||||
|
||||
MCP_BEARER_ENV = "ARR_MCP_BEARER_TOKEN"
|
||||
ASGIApp = Callable[
|
||||
[Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]],
|
||||
Awaitable[None],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BearerAuthConfig:
|
||||
token: str = field(repr=False)
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "BearerAuthConfig":
|
||||
value = os.environ.get(MCP_BEARER_ENV, "")
|
||||
try:
|
||||
encoded = value.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
encoded = b""
|
||||
if (
|
||||
not 32 <= len(encoded) <= 256
|
||||
or any(byte <= 32 or byte == 127 for byte in encoded)
|
||||
):
|
||||
raise ValueError(f"{MCP_BEARER_ENV} is unavailable or invalid")
|
||||
return cls(value)
|
||||
|
||||
|
||||
class BearerAuthASGI:
|
||||
"""Pure ASGI middleware; never logs or exposes the configured token."""
|
||||
|
||||
def __init__(self, application: ASGIApp, config: BearerAuthConfig) -> None:
|
||||
self._application = application
|
||||
self._expected_sha256 = hashlib.sha256(
|
||||
("Bearer " + config.token).encode("ascii")
|
||||
).digest()
|
||||
|
||||
@staticmethod
|
||||
def _authorization_values(scope: Dict[str, Any]) -> List[bytes]:
|
||||
headers: List[Tuple[bytes, bytes]] = scope.get("headers", [])
|
||||
return [
|
||||
value
|
||||
for name, value in headers
|
||||
if name.lower() == b"authorization"
|
||||
]
|
||||
|
||||
def _authorized(self, scope: Dict[str, Any]) -> bool:
|
||||
values = self._authorization_values(scope)
|
||||
if len(values) != 1:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
hashlib.sha256(values[0]).digest(),
|
||||
self._expected_sha256,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _reject(
|
||||
send: Callable[[Dict[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
body = b'{"error":"unauthorized"}'
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode("ascii")),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"www-authenticate", b"Bearer"),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
scope: Dict[str, Any],
|
||||
receive: Callable[[], Awaitable[Dict[str, Any]]],
|
||||
send: Callable[[Dict[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
if scope.get("type") == "http" and not self._authorized(scope):
|
||||
await self._reject(send)
|
||||
return
|
||||
await self._application(scope, receive, send)
|
||||
67
arr_mcp/database.py
Normal file
67
arr_mcp/database.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Controlled local PostgreSQL connector for the standalone MCP process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
|
||||
_REQUIRED = (
|
||||
"ARR_DB_HOST",
|
||||
"ARR_DB_PORT",
|
||||
"ARR_DB_USER",
|
||||
"ARR_DB_PASSWORD",
|
||||
"ARR_DB_NAME",
|
||||
)
|
||||
|
||||
|
||||
def _read_private_database_config(path: Path) -> Dict[str, object]:
|
||||
candidate = path.expanduser()
|
||||
if candidate.is_symlink():
|
||||
raise ValueError("controlled database configuration is invalid")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
metadata = resolved.stat()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
raise ValueError("controlled database configuration is invalid")
|
||||
values: Dict[str, str] = {}
|
||||
for raw in resolved.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in values:
|
||||
raise ValueError("controlled database configuration is invalid")
|
||||
values[key] = value.strip().strip('"').strip("'")
|
||||
if any(not values.get(key) for key in _REQUIRED):
|
||||
raise ValueError("controlled database configuration is incomplete")
|
||||
try:
|
||||
port = int(values["ARR_DB_PORT"])
|
||||
except ValueError:
|
||||
raise ValueError("controlled database configuration is invalid") from None
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("controlled database configuration is invalid")
|
||||
return {
|
||||
"host": values["ARR_DB_HOST"],
|
||||
"port": port,
|
||||
"user": values["ARR_DB_USER"],
|
||||
"password": values["ARR_DB_PASSWORD"],
|
||||
"dbname": values["ARR_DB_NAME"],
|
||||
}
|
||||
|
||||
|
||||
def controlled_connect(path: Path) -> Callable[[str], Any]:
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
|
||||
parameters = _read_private_database_config(path)
|
||||
|
||||
def connect(_dsn: str) -> Any:
|
||||
return psycopg.connect(**parameters, autocommit=False)
|
||||
|
||||
return connect
|
||||
45
arr_mcp/gateway.py
Normal file
45
arr_mcp/gateway.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Framework-neutral adapter for the single ARR MCP tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, Protocol
|
||||
|
||||
from arr_ingestion.contracts import IngestionError
|
||||
from arr_ingestion.direct_contracts import DirectSubmissionReceipt
|
||||
|
||||
|
||||
class DirectResultSubmitter(Protocol):
|
||||
def submit(self, raw_request: Any) -> DirectSubmissionReceipt:
|
||||
...
|
||||
|
||||
|
||||
class DirectResultGateway:
|
||||
"""Expose one bounded command without duplicating ingestion logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: DirectResultSubmitter,
|
||||
*,
|
||||
max_concurrency: int = 2,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(max_concurrency, int)
|
||||
or isinstance(max_concurrency, bool)
|
||||
or not 1 <= max_concurrency <= 8
|
||||
):
|
||||
raise ValueError("direct gateway concurrency is invalid")
|
||||
self._service = service
|
||||
self._slots = threading.BoundedSemaphore(max_concurrency)
|
||||
|
||||
def submit_processing_result(self, arguments: Any) -> Dict[str, Any]:
|
||||
if not self._slots.acquire(blocking=False):
|
||||
raise IngestionError(
|
||||
"DIRECT_GATEWAY_BUSY",
|
||||
"direct result gateway is busy; retry later",
|
||||
retryable=True,
|
||||
)
|
||||
try:
|
||||
return self._service.submit(arguments).to_dict()
|
||||
finally:
|
||||
self._slots.release()
|
||||
108
arr_mcp/launch.py
Normal file
108
arr_mcp/launch.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Local secure launcher that loads routes and Keychain-backed MCP secrets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Sequence
|
||||
|
||||
from arr_mcp.run import main as run_mcp
|
||||
|
||||
|
||||
KEYCHAIN_ACCOUNT = "arr-web"
|
||||
KEYCHAIN_SERVICES = {
|
||||
"OSS_ACCESS_KEY_ID": "com.chillishark.arr.oss-access-key-id",
|
||||
"OSS_ACCESS_KEY_SECRET": "com.chillishark.arr.oss-access-key-secret",
|
||||
"ARR_MCP_BEARER_TOKEN": "com.chillishark.arr.mcp-bearer",
|
||||
}
|
||||
ROUTE_KEYS = frozenset(
|
||||
{
|
||||
"ARR_OSS_REGION",
|
||||
"ARR_OSS_ENDPOINT",
|
||||
"ARR_OSS_BUCKET",
|
||||
"ARR_OBJECT_PREFIX",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _private_route_values(path: Path) -> Dict[str, str]:
|
||||
candidate = path.expanduser()
|
||||
if candidate.is_symlink():
|
||||
raise ValueError("ARR MCP route configuration is invalid")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
metadata = resolved.stat()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
raise ValueError("ARR MCP route configuration is invalid")
|
||||
values: Dict[str, str] = {}
|
||||
for raw in resolved.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in ROUTE_KEYS:
|
||||
if key in values:
|
||||
raise ValueError("ARR MCP route configuration is invalid")
|
||||
values[key] = value.strip().strip('"').strip("'")
|
||||
if any(not values.get(key) for key in ("ARR_OSS_REGION", "ARR_OSS_BUCKET")):
|
||||
raise ValueError("ARR MCP route configuration is incomplete")
|
||||
return values
|
||||
|
||||
|
||||
def _keychain_secret(service: str) -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
raise ValueError("required ARR MCP Keychain item is unavailable") from None
|
||||
value = completed.stdout.strip()
|
||||
if not value:
|
||||
raise ValueError("required ARR MCP Keychain item is unavailable")
|
||||
return value
|
||||
|
||||
|
||||
def load_runtime_environment(route_config: Path) -> None:
|
||||
for key, value in _private_route_values(route_config).items():
|
||||
os.environ[key] = value
|
||||
for environment_name, service in KEYCHAIN_SERVICES.items():
|
||||
os.environ[environment_name] = _keychain_secret(service)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument(
|
||||
"--route-config",
|
||||
type=Path,
|
||||
default=Path("~/.config/arr/agent-writeback.env"),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
args, remaining = _parser().parse_known_args(argv)
|
||||
load_runtime_environment(args.route_config)
|
||||
return run_mcp(remaining)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
arr_mcp/run.py
Normal file
89
arr_mcp/run.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Standalone Streamable HTTP entry point for ARR MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from arr_mcp.auth import BearerAuthConfig
|
||||
from arr_mcp.database import controlled_connect
|
||||
from arr_mcp.gateway import DirectResultGateway
|
||||
from arr_mcp.server import create_http_application
|
||||
from arr_web.direct_ingestion_runtime import compose_oss_direct_ingestion
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="arr-mcp")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8890)
|
||||
parser.add_argument(
|
||||
"--db-config",
|
||||
type=Path,
|
||||
help="private 0600 ARR_DB_* configuration file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowed-host",
|
||||
action="append",
|
||||
default=[],
|
||||
help="exact HTTP Host value or host:*; repeat for reverse-proxy names",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-concurrency",
|
||||
type=int,
|
||||
default=2,
|
||||
help="maximum concurrent deterministic replays (1-8)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _allowed_hosts(bind_host: str, configured: list[str]) -> list[str]:
|
||||
if configured:
|
||||
return list(dict.fromkeys(configured))
|
||||
if bind_host not in _LOOPBACK_HOSTS:
|
||||
raise ValueError(
|
||||
"--allowed-host is required when ARR MCP binds beyond loopback"
|
||||
)
|
||||
return ["localhost:*", "127.0.0.1:*", "[::1]:*"]
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
if not 1 <= args.port <= 65535:
|
||||
raise ValueError("ARR MCP port is invalid")
|
||||
connect = controlled_connect(args.db_config) if args.db_config else None
|
||||
runtime = compose_oss_direct_ingestion(
|
||||
project_root=PROJECT_ROOT,
|
||||
connect=connect,
|
||||
)
|
||||
try:
|
||||
runtime.service.assert_ready()
|
||||
gateway = DirectResultGateway(
|
||||
runtime.service,
|
||||
max_concurrency=args.max_concurrency,
|
||||
)
|
||||
application = create_http_application(
|
||||
gateway,
|
||||
BearerAuthConfig.from_environment(),
|
||||
allowed_hosts=_allowed_hosts(args.host, args.allowed_host),
|
||||
)
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
application,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
access_log=True,
|
||||
log_level="info",
|
||||
)
|
||||
finally:
|
||||
runtime.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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