Add independent MCP PDF report delivery
This commit is contained in:
+122
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user