Add stdio MCP server for RollCalc
This commit is contained in:
@@ -114,6 +114,39 @@ Implemented routes:
|
|||||||
|
|
||||||
Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BETA_USERS` with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
|
Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BETA_USERS` with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
|
||||||
|
|
||||||
|
## MCP PoC
|
||||||
|
|
||||||
|
`mcp_server.py` provides a local stdio-only Model Context Protocol server. It is
|
||||||
|
a thin adapter over the same domain services used by Flask: article lookup,
|
||||||
|
direct roll calculation, and transport analysis. The available PoC tools are
|
||||||
|
`get_article`, `calculate_roll_diameter`, and `analyze_transport_capacity`.
|
||||||
|
|
||||||
|
Install the pinned dependencies, including `mcp==1.26.0`, in the existing
|
||||||
|
environment, then start it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pip install -r requirements.txt
|
||||||
|
.venv/bin/python mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The PoC has no MCP resources, HTTP/SSE transport, remote authentication, or
|
||||||
|
dedicated product-length or target-length calculation tools, or extrapolation.
|
||||||
|
|
||||||
|
Tool calling semantics:
|
||||||
|
|
||||||
|
- `get_article` accepts textual `article_number` values (including leading
|
||||||
|
zeroes) and an optional `article_name_hint`.
|
||||||
|
- `calculate_roll_diameter` uses `roll_length_m` for the material length on one
|
||||||
|
roll. Diameter and thickness inputs are in mm; `width_m` is in m;
|
||||||
|
`area_weight_g_m2` is in g/m². `include_roll_weight` requests the optional
|
||||||
|
kilogram result.
|
||||||
|
- `analyze_transport_capacity` uses roll/core diameters in mm; roll width and
|
||||||
|
transport dimensions in m; and roll/payload weights in kg.
|
||||||
|
`product_length_m` is the material length represented by one roll, used only
|
||||||
|
for reported square metres. The supplied `roll_weight_kg` is passed through
|
||||||
|
unchanged. Callers may use a canonical `transport_preset` or provide custom
|
||||||
|
transport dimensions and `max_weight_kg`.
|
||||||
|
|
||||||
Generate a password hash or user entry with:
|
Generate a password hash or user entry with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""RollCalc's minimal stdio MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rollcalc_mcp_tools import (
|
||||||
|
analyze_transport_capacity_result,
|
||||||
|
calculate_roll_diameter_result,
|
||||||
|
get_article_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_server() -> Any:
|
||||||
|
"""Create the stdio-only MCP server after the SDK is installed."""
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
server = FastMCP("RollCalc")
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def get_article(
|
||||||
|
article_number: str | None = None,
|
||||||
|
article_name_hint: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve RollCalc article master data. article_number is text (including leading zeroes); optional article_name_hint preserves existing conflict and ambiguity checks."""
|
||||||
|
return get_article_result(article_number, article_name_hint)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def calculate_roll_diameter(
|
||||||
|
roll_length_m: float | None = None,
|
||||||
|
core_diameter_mm: float | None = None,
|
||||||
|
thickness_mm: float | None = None,
|
||||||
|
thickness_stddev_mm: float | None = None,
|
||||||
|
article_number: str | None = None,
|
||||||
|
article_name_hint: str | None = None,
|
||||||
|
core_type: str | None = None,
|
||||||
|
width_m: float | None = None,
|
||||||
|
area_weight_g_m2: float | None = None,
|
||||||
|
include_roll_weight: bool = False,
|
||||||
|
category: str | None = None,
|
||||||
|
production_site: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate roll diameter. roll_length_m is the material length on one roll in m; core_diameter_mm, thickness_mm, and thickness_stddev_mm are mm; width_m is m; and area_weight_g_m2 is g/m². Resolve an article or provide applicable manual material inputs. Set include_roll_weight only to request the existing optional roll_weight_kg result."""
|
||||||
|
return calculate_roll_diameter_result(
|
||||||
|
article_number=article_number,
|
||||||
|
article_name_hint=article_name_hint,
|
||||||
|
roll_length_m=roll_length_m,
|
||||||
|
width_m=width_m,
|
||||||
|
thickness_mm=thickness_mm,
|
||||||
|
thickness_stddev_mm=thickness_stddev_mm,
|
||||||
|
area_weight_g_m2=area_weight_g_m2,
|
||||||
|
core_diameter_mm=core_diameter_mm,
|
||||||
|
core_type=core_type,
|
||||||
|
category=category,
|
||||||
|
production_site=production_site,
|
||||||
|
include_roll_weight=include_roll_weight,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def analyze_transport_capacity(
|
||||||
|
roll_diameter_mm: float | None = None,
|
||||||
|
core_diameter_mm: float | None = None,
|
||||||
|
roll_width_m: float | None = None,
|
||||||
|
roll_weight_kg: float | None = None,
|
||||||
|
product_length_m: float | None = None,
|
||||||
|
transport_preset: str | None = None,
|
||||||
|
length_m: float | None = None,
|
||||||
|
width_m: float | None = None,
|
||||||
|
height_m: float | None = None,
|
||||||
|
max_weight_kg: float | None = None,
|
||||||
|
margin_side_m: float | None = None,
|
||||||
|
margin_ceiling_m: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Analyze transport capacity. roll_diameter_mm and core_diameter_mm are mm; roll_width_m and product_length_m are m; roll_weight_kg and max_weight_kg are kg. product_length_m is the material length represented by one roll and is used for reported m², not placement geometry. Use a shared transport_preset or supply all custom transport dimensions in m and max_weight_kg. roll_weight_kg is consumed exactly as supplied."""
|
||||||
|
return analyze_transport_capacity_result(
|
||||||
|
transport_preset=transport_preset,
|
||||||
|
length_m=length_m,
|
||||||
|
width_m=width_m,
|
||||||
|
height_m=height_m,
|
||||||
|
max_weight_kg=max_weight_kg,
|
||||||
|
margin_side_m=margin_side_m,
|
||||||
|
margin_ceiling_m=margin_ceiling_m,
|
||||||
|
roll_diameter_mm=roll_diameter_mm,
|
||||||
|
core_diameter_mm=core_diameter_mm,
|
||||||
|
roll_width_m=roll_width_m,
|
||||||
|
roll_weight_kg=roll_weight_kg,
|
||||||
|
product_length_m=product_length_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_server().run(transport="stdio")
|
||||||
@@ -5,3 +5,4 @@ click==8.1.7
|
|||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.3
|
MarkupSafe==2.1.3
|
||||||
|
mcp==1.26.0
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Thin MCP-facing adapters over the shared RollCalc domain services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from roll_calculation import calculate_roll, get_article
|
||||||
|
from transport_calculation import analyze_transport
|
||||||
|
|
||||||
|
|
||||||
|
def get_article_result(
|
||||||
|
article_number: str | None = None,
|
||||||
|
article_name_hint: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delegate article resolution to the shared article repository."""
|
||||||
|
return get_article(article_number, article_name_hint)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_roll_diameter_result(
|
||||||
|
*,
|
||||||
|
article_number: str | None = None,
|
||||||
|
article_name_hint: str | None = None,
|
||||||
|
roll_length_m: float | None = None,
|
||||||
|
width_m: float | None = None,
|
||||||
|
thickness_mm: float | None = None,
|
||||||
|
thickness_stddev_mm: float | None = None,
|
||||||
|
area_weight_g_m2: float | None = None,
|
||||||
|
core_diameter_mm: float | None = None,
|
||||||
|
core_type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
production_site: str | None = None,
|
||||||
|
include_roll_weight: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Map MCP arguments to the existing CalculationRequest input shape."""
|
||||||
|
return calculate_roll({
|
||||||
|
"article_number": article_number,
|
||||||
|
"article_name_hint": article_name_hint,
|
||||||
|
"roll_length_m": roll_length_m,
|
||||||
|
"width_m": width_m,
|
||||||
|
"thickness_mm": thickness_mm,
|
||||||
|
"thickness_stddev_mm": thickness_stddev_mm,
|
||||||
|
"area_weight_g_m2": area_weight_g_m2,
|
||||||
|
"core_diameter_mm": core_diameter_mm,
|
||||||
|
"core_type": core_type,
|
||||||
|
"category": category,
|
||||||
|
"production_site": production_site,
|
||||||
|
"include_roll_weight": include_roll_weight,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_transport_capacity_result(
|
||||||
|
*,
|
||||||
|
transport_preset: str | None = None,
|
||||||
|
length_m: float | None = None,
|
||||||
|
width_m: float | None = None,
|
||||||
|
height_m: float | None = None,
|
||||||
|
max_weight_kg: float | None = None,
|
||||||
|
margin_side_m: float | None = None,
|
||||||
|
margin_ceiling_m: float | None = None,
|
||||||
|
roll_diameter_mm: float | None = None,
|
||||||
|
core_diameter_mm: float | None = None,
|
||||||
|
roll_width_m: float | None = None,
|
||||||
|
roll_weight_kg: float | None = None,
|
||||||
|
product_length_m: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Map MCP arguments to the shared transport-analysis input shape."""
|
||||||
|
return analyze_transport({
|
||||||
|
"transport_preset": transport_preset,
|
||||||
|
"length_m": length_m,
|
||||||
|
"width_m": width_m,
|
||||||
|
"height_m": height_m,
|
||||||
|
"max_weight_kg": max_weight_kg,
|
||||||
|
"margin_side_m": margin_side_m,
|
||||||
|
"margin_ceiling_m": margin_ceiling_m,
|
||||||
|
"roll_diameter_mm": roll_diameter_mm,
|
||||||
|
"core_diameter_mm": core_diameter_mm,
|
||||||
|
"roll_width_m": roll_width_m,
|
||||||
|
"roll_weight_kg": roll_weight_kg,
|
||||||
|
"product_length_m": product_length_m,
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
from roll_calculation import calculate_roll, get_article
|
||||||
|
from rollcalc_mcp_tools import (
|
||||||
|
analyze_transport_capacity_result,
|
||||||
|
calculate_roll_diameter_result,
|
||||||
|
get_article_result,
|
||||||
|
)
|
||||||
|
from transport_calculation import analyze_transport
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFastMCP:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
self.tools = []
|
||||||
|
|
||||||
|
def tool(self):
|
||||||
|
def register(function):
|
||||||
|
self.tools.append(function)
|
||||||
|
return function
|
||||||
|
return register
|
||||||
|
|
||||||
|
|
||||||
|
class McpAdapterTests(unittest.TestCase):
|
||||||
|
def test_get_article_delegates_to_the_domain_for_success_not_found_and_conflict(self):
|
||||||
|
for arguments in (
|
||||||
|
{"article_number": "146900"},
|
||||||
|
{"article_number": "does-not-exist"},
|
||||||
|
{"article_number": "146900", "article_name_hint": "Other product"},
|
||||||
|
):
|
||||||
|
with self.subTest(arguments=arguments):
|
||||||
|
self.assertEqual(
|
||||||
|
get_article_result(**arguments),
|
||||||
|
get_article(**arguments),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
|
||||||
|
for arguments in (
|
||||||
|
{
|
||||||
|
"article_number": "146900",
|
||||||
|
"roll_length_m": 50.0,
|
||||||
|
"core_diameter_mm": 150.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"article_number": "146900",
|
||||||
|
"roll_length_m": 50.0,
|
||||||
|
"core_diameter_mm": 150.0,
|
||||||
|
"width_m": 5.8,
|
||||||
|
"area_weight_g_m2": 1767.2,
|
||||||
|
"include_roll_weight": True,
|
||||||
|
},
|
||||||
|
{"roll_length_m": -1.0, "core_diameter_mm": 150.0, "thickness_mm": 2.0},
|
||||||
|
):
|
||||||
|
with self.subTest(arguments=arguments):
|
||||||
|
self.assertEqual(
|
||||||
|
calculate_roll_diameter_result(**arguments),
|
||||||
|
calculate_roll(arguments),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transport_tool_delegates_for_preset_custom_and_invalid_cases(self):
|
||||||
|
for arguments in (
|
||||||
|
{
|
||||||
|
"transport_preset": "container_20ft",
|
||||||
|
"roll_diameter_mm": 1000.0,
|
||||||
|
"core_diameter_mm": 150.0,
|
||||||
|
"roll_width_m": 2.0,
|
||||||
|
"roll_weight_kg": 1000.0,
|
||||||
|
"product_length_m": 50.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"length_m": 10.0,
|
||||||
|
"width_m": 5.0,
|
||||||
|
"height_m": 5.0,
|
||||||
|
"max_weight_kg": 10000.0,
|
||||||
|
"roll_diameter_mm": 1000.0,
|
||||||
|
"core_diameter_mm": 150.0,
|
||||||
|
"roll_width_m": 1.0,
|
||||||
|
"roll_weight_kg": 1000.0,
|
||||||
|
},
|
||||||
|
{"length_m": 0.0},
|
||||||
|
):
|
||||||
|
with self.subTest(arguments=arguments):
|
||||||
|
self.assertEqual(
|
||||||
|
analyze_transport_capacity_result(**arguments),
|
||||||
|
analyze_transport(arguments),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_registers_exactly_the_three_stdio_poc_tools(self):
|
||||||
|
mcp = types.ModuleType("mcp")
|
||||||
|
server = types.ModuleType("mcp.server")
|
||||||
|
fastmcp = types.ModuleType("mcp.server.fastmcp")
|
||||||
|
fastmcp.FastMCP = _FakeFastMCP
|
||||||
|
with patch.dict(sys.modules, {
|
||||||
|
"mcp": mcp,
|
||||||
|
"mcp.server": server,
|
||||||
|
"mcp.server.fastmcp": fastmcp,
|
||||||
|
}):
|
||||||
|
instance = mcp_server.create_server()
|
||||||
|
|
||||||
|
self.assertEqual(instance.name, "RollCalc")
|
||||||
|
self.assertEqual(
|
||||||
|
[tool.__name__ for tool in instance.tools],
|
||||||
|
[
|
||||||
|
"get_article",
|
||||||
|
"calculate_roll_diameter",
|
||||||
|
"analyze_transport_capacity",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user