Add independent MCP PDF report delivery

This commit is contained in:
2026-09-27 11:09:13 +02:00
parent 0c18c86a47
commit 6c36d78063
9 changed files with 555 additions and 20 deletions
+122 -1
View File
@@ -1,6 +1,10 @@
import copy
import asyncio
import inspect
import os
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
@@ -20,6 +24,7 @@ from rollcalc_mcp_tools import (
calculate_material_weight_result,
calculate_product_length_result,
calculate_roll_diameter_result,
generate_calculation_pdf_result,
get_article_result,
get_machine_result,
project_machine_max_product_length_result,
@@ -31,12 +36,15 @@ from machine_constraints import (
get_machine,
)
from transport_calculation import analyze_transport, transport_presets
from mcp_pdf_delivery import McpReportNotFoundError, McpReportStore, REPORT_TTL_SECONDS
from starlette.requests import Request
class _FakeFastMCP:
def __init__(self, name):
def __init__(self, name, **kwargs):
self.name = name
self.tools = []
self.routes = []
def tool(self):
def register(function):
@@ -44,6 +52,12 @@ class _FakeFastMCP:
return function
return register
def custom_route(self, path, methods):
def register(function):
self.routes.append((path, methods, function))
return function
return register
class McpAdapterTests(unittest.TestCase):
def test_get_article_delegates_to_the_domain_for_success_not_found_and_conflict(self):
@@ -340,6 +354,96 @@ class McpAdapterTests(unittest.TestCase):
helper.assert_called_once()
self.assertEqual(result["transport_roll_inputs"], expected_inputs)
def test_generate_pdf_reuses_authoritative_report_path(self):
arguments = {
"article_number": "146900",
"roll_length_m": 50.0,
"core_diameter_mm": 150.0,
"core_type": "150mm PVC",
"category": "bentofix",
}
with tempfile.TemporaryDirectory() as directory:
with patch.dict(os.environ, {
"ROLLCALC_MCP_ARTIFACT_DIRECTORY": directory,
"ROLLCALC_MCP_ARTIFACT_PUBLIC_BASE_URL": (
"https://mcp.example.test/rollcalc"
),
}):
response = generate_calculation_pdf_result(**arguments)
token = response["download_url"].rsplit("/", 1)[1][:-4]
pdf, stored = McpReportStore().read(token)
self.assertEqual(response["status"], "success")
self.assertEqual(stored.filename, response["filename"])
self.assertTrue(pdf.startswith(b"%PDF-1.4"))
self.assertIn(b"146900", pdf)
self.assertIn(b"CALCULATION RESULTS", pdf)
self.assertEqual(
response["download_url"],
f"https://mcp.example.test/rollcalc/reports/{token}.pdf",
)
self.assertEqual(len(token), 43)
self.assertRegex(token, r"^[A-Za-z0-9_-]+$")
self.assertEqual(
set(response),
{"status", "filename", "expires_at", "expires_in_seconds", "download_url"},
)
self.assertEqual(response["expires_in_seconds"], REPORT_TTL_SECONDS)
def test_generate_pdf_preserves_structured_calculation_errors(self):
result = generate_calculation_pdf_result(
article_number="146900",
roll_length_m=None,
core_diameter_mm=150.0,
)
self.assertEqual(result["status"], "needs_clarification")
self.assertIn("roll_length_m", result["missing"])
def test_mcp_report_store_rejects_expired_unknown_and_path_tokens(self):
with tempfile.TemporaryDirectory() as directory:
store = McpReportStore(directory=Path(directory))
with patch("mcp_pdf_delivery.time.time", return_value=1_000.0):
report = store.create(b"%PDF-1.4", "roll-calculation_146900.pdf")
with patch(
"mcp_pdf_delivery.time.time",
return_value=1_000.0 + REPORT_TTL_SECONDS + 1,
):
with self.assertRaises(McpReportNotFoundError):
store.read(report.token)
for token in ("unknown", "../access_log", "a" * 43 + "/x"):
with self.subTest(token=token):
with self.assertRaises(McpReportNotFoundError):
store.read(token)
def test_mcp_host_route_downloads_only_valid_unexpired_reports(self):
with tempfile.TemporaryDirectory() as directory:
with patch.dict(os.environ, {
"ROLLCALC_MCP_ARTIFACT_DIRECTORY": directory,
}):
stored = McpReportStore().create(
b"%PDF-1.4\nMCP report", "roll-calculation_146900.pdf"
)
response = asyncio.run(mcp_server.download_report(
Request({"type": "http", "path_params": {"token": stored.token}})
))
unknown = asyncio.run(mcp_server.download_report(
Request({"type": "http", "path_params": {"token": "x" * 43}})
))
traversal = asyncio.run(mcp_server.download_report(
Request({"type": "http", "path_params": {"token": "../access_log"}})
))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["content-type"], "application/pdf")
self.assertEqual(
response.headers["content-disposition"],
'attachment; filename="roll-calculation_146900.pdf"',
)
self.assertEqual(response.body, b"%PDF-1.4\nMCP report")
self.assertEqual(unknown.status_code, 404)
self.assertEqual(traversal.status_code, 404)
def test_transport_tool_delegates_to_domain_with_lkw_sattelzug_preset(self):
arguments = {
"transport_preset": "lkw_sattelzug",
@@ -384,9 +488,22 @@ class McpAdapterTests(unittest.TestCase):
"calculate_material_weight",
"calculate_product_length",
"calculate_roll_diameter",
"generate_calculation_pdf",
"analyze_transport_capacity",
],
)
self.assertEqual(
[(path, methods) for path, methods, _ in instance.routes],
[("/reports/{token}.pdf", ["GET"])],
)
pdf_tool = next(
tool for tool in instance.tools
if tool.__name__ == "generate_calculation_pdf"
)
with patch.dict(os.environ, {"ROLLCALC_MCP_TRANSPORT": "stdio"}):
self.assertEqual(
pdf_tool()["status"], "artifact_delivery_unavailable"
)
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
instance = mcp_server.create_server()
@@ -398,6 +515,7 @@ class McpAdapterTests(unittest.TestCase):
material_weight_tool = tools["calculate_material_weight"]
product_length_tool = tools["calculate_product_length"]
roll_schema = tools["calculate_roll_diameter"].parameters
pdf_tool = tools["generate_calculation_pdf"]
transport_tool = tools["analyze_transport_capacity"]
transport_schema = transport_tool.parameters
@@ -408,6 +526,9 @@ class McpAdapterTests(unittest.TestCase):
)
self.assertIn("average_diameter_mm", feasibility_tool.parameters["properties"])
self.assertIn("maximum_diameter_mm", feasibility_tool.parameters["properties"])
self.assertIn("explicitly requests", pdf_tool.description)
self.assertIn("Do not call this tool after ordinary calculations", pdf_tool.description)
self.assertNotIn("minimum_diameter_mm", pdf_tool.parameters["properties"])
self.assertIn("tool, not the LLM, decides", feasibility_tool.description)
self.assertIn("never override failed constraints", feasibility_tool.description)
self.assertIn("warnings", feasibility_tool.description)