Files
2026-03-08 20:00:24 +01:00

309 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Local MCP server for OpenSCAD headless operations."""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from cad_runtime import ( # type: ignore
CadRuntimeError,
cleanup_runtime_path,
coerce_workspace_path,
create_runtime_temp_dir,
last_nonempty_line,
require_process_success,
run_cad_stack,
)
from mcp_stdio import ( # type: ignore
PROTOCOL_VERSION,
error_tool_result,
make_error,
make_response,
ok_tool_result,
read_message,
write_message,
)
from mcp_telemetry import emit_mcp_span # type: ignore
ROOT = Path(__file__).resolve().parents[1]
TOOLS = [
{
"name": "get_runtime_info",
"description": "Return the resolved OpenSCAD headless runtime version and wrapper metadata.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "validate_model",
"description": "Validate an OpenSCAD model by compiling it to a temporary artifact.",
"inputSchema": {
"type": "object",
"properties": {
"source": {"type": "string", "description": "OpenSCAD source code"},
},
"required": ["source"],
},
},
{
"name": "render_model",
"description": "Render an OpenSCAD model to a workspace artifact.",
"inputSchema": {
"type": "object",
"properties": {
"source": {"type": "string", "description": "OpenSCAD source code"},
"output_path": {"type": "string", "description": "Workspace-relative .stl path"},
},
"required": ["source", "output_path"],
},
},
{
"name": "export_model",
"description": "Alias of render_model for explicit export workflows.",
"inputSchema": {
"type": "object",
"properties": {
"source": {"type": "string", "description": "OpenSCAD source code"},
"output_path": {"type": "string", "description": "Workspace-relative .stl path"},
},
"required": ["source", "output_path"],
},
},
]
def _tool_error(code: str, message: str, **extra: Any) -> dict[str, Any]:
payload = {"ok": False, "error": {"code": code, "message": message}}
payload.update(extra)
return error_tool_result(message, payload)
def _run_tool(tool_name: str, callback) -> dict[str, Any]:
started = time.perf_counter()
try:
result = callback()
emit_mcp_span(
server_name="openscad",
operation="tools/call",
tool_name=tool_name,
status="ok",
duration_ms=(time.perf_counter() - started) * 1000,
)
return result
except CadRuntimeError as exc:
emit_mcp_span(
server_name="openscad",
operation="tools/call",
tool_name=tool_name,
status="error",
error=str(exc),
duration_ms=(time.perf_counter() - started) * 1000,
)
return _tool_error("runtime_error", str(exc))
except Exception as exc:
emit_mcp_span(
server_name="openscad",
operation="tools/call",
tool_name=tool_name,
status="error",
error=str(exc),
duration_ms=(time.perf_counter() - started) * 1000,
)
return _tool_error("unexpected_error", str(exc))
def _write_source(temp_dir: Path, source: str) -> Path:
if not source.strip():
raise CadRuntimeError("source is required")
source_path = temp_dir / "model.scad"
source_path.write_text(source, encoding="utf-8")
return source_path
def tool_get_runtime_info(_: dict[str, Any]) -> dict[str, Any]:
proc = require_process_success(
run_cad_stack("openscad", "--version", timeout=45.0),
context="openscad runtime info",
)
version = last_nonempty_line(proc.stdout) or last_nonempty_line(proc.stderr)
if not version:
raise CadRuntimeError("openscad version probe returned no version")
return ok_tool_result(
version,
{
"ok": True,
"runtime": "openscad-headless",
"version": version,
"workspace_root": str(ROOT),
},
)
def tool_validate_model(arguments: dict[str, Any]) -> dict[str, Any]:
source = str(arguments.get("source") or "")
temp_dir = create_runtime_temp_dir("openscad-mcp")
try:
source_path = _write_source(temp_dir, source)
output_path = temp_dir / "validate.stl"
require_process_success(
run_cad_stack(
"openscad",
"-o",
str(output_path.relative_to(ROOT)),
str(source_path.relative_to(ROOT)),
timeout=60.0,
),
context="openscad validate model",
)
if not output_path.exists() or output_path.stat().st_size <= 0:
raise CadRuntimeError("openscad validation produced no artifact")
return ok_tool_result(
"Validated OpenSCAD model",
{
"ok": True,
"artifact_size_bytes": output_path.stat().st_size,
},
)
finally:
cleanup_runtime_path(temp_dir)
def _render_like(source: str, output_path_value: str) -> dict[str, Any]:
output_path = coerce_workspace_path(output_path_value, suffix=".stl")
temp_dir = create_runtime_temp_dir("openscad-mcp")
try:
source_path = _write_source(temp_dir, source)
require_process_success(
run_cad_stack(
"openscad",
"-o",
str(output_path.relative_to(ROOT)),
str(source_path.relative_to(ROOT)),
timeout=90.0,
),
context="openscad render model",
)
if not output_path.exists() or output_path.stat().st_size <= 0:
raise CadRuntimeError(f"openscad output missing or empty: {output_path}")
return {
"ok": True,
"output_path": str(output_path.relative_to(ROOT)),
"size_bytes": output_path.stat().st_size,
}
finally:
cleanup_runtime_path(temp_dir)
def tool_render_model(arguments: dict[str, Any]) -> dict[str, Any]:
source = str(arguments.get("source") or "")
output_path_value = str(arguments.get("output_path") or "")
if not output_path_value:
raise CadRuntimeError("output_path is required")
payload = _render_like(source, output_path_value)
return ok_tool_result("Rendered OpenSCAD model", payload)
def tool_export_model(arguments: dict[str, Any]) -> dict[str, Any]:
source = str(arguments.get("source") or "")
output_path_value = str(arguments.get("output_path") or "")
if not output_path_value:
raise CadRuntimeError("output_path is required")
payload = _render_like(source, output_path_value)
return ok_tool_result("Exported OpenSCAD model", payload)
def serve_mcp() -> int:
while True:
started = time.perf_counter()
request = read_message()
if request is None:
return 0
method = request.get("method")
request_id = request.get("id")
params = request.get("params") or {}
if method == "initialize":
write_message(
make_response(
request_id,
{
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "openscad", "version": "1.0.0"},
},
)
)
emit_mcp_span(
server_name="openscad",
operation="initialize",
status="ok",
duration_ms=(time.perf_counter() - started) * 1000,
)
continue
if method == "notifications/initialized":
continue
if method == "ping":
write_message(make_response(request_id, {}))
emit_mcp_span(
server_name="openscad",
operation="ping",
status="ok",
duration_ms=(time.perf_counter() - started) * 1000,
)
continue
if method == "tools/list":
write_message(make_response(request_id, {"tools": TOOLS}))
emit_mcp_span(
server_name="openscad",
operation="tools/list",
status="ok",
duration_ms=(time.perf_counter() - started) * 1000,
)
continue
if method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments") or {}
if tool_name == "get_runtime_info":
write_message(make_response(request_id, _run_tool("get_runtime_info", lambda: tool_get_runtime_info(arguments))))
continue
if tool_name == "validate_model":
write_message(make_response(request_id, _run_tool("validate_model", lambda: tool_validate_model(arguments))))
continue
if tool_name == "render_model":
write_message(make_response(request_id, _run_tool("render_model", lambda: tool_render_model(arguments))))
continue
if tool_name == "export_model":
write_message(make_response(request_id, _run_tool("export_model", lambda: tool_export_model(arguments))))
continue
write_message(make_error(request_id, -32602, f"Unknown tool: {tool_name}"))
emit_mcp_span(
server_name="openscad",
operation="tools/call",
tool_name=str(tool_name),
status="error",
error=f"Unknown tool: {tool_name}",
duration_ms=(time.perf_counter() - started) * 1000,
)
continue
if request_id is not None:
write_message(make_error(request_id, -32601, f"Method not found: {method}"))
emit_mcp_span(
server_name="openscad",
operation=str(method),
status="error",
error=f"Method not found: {method}",
duration_ms=(time.perf_counter() - started) * 1000,
)
if __name__ == "__main__":
raise SystemExit(serve_mcp())