diff --git a/mcp-servers/navi-3d/app/__init__.py b/mcp-servers/navi-3d/app/__init__.py new file mode 100644 index 0000000..a2ea1cc --- /dev/null +++ b/mcp-servers/navi-3d/app/__init__.py @@ -0,0 +1 @@ +"""mcp-server-navi-3d — 3D modeling MCP tools.""" diff --git a/mcp-servers/navi-3d/app/config.py b/mcp-servers/navi-3d/app/config.py new file mode 100644 index 0000000..04f7a89 --- /dev/null +++ b/mcp-servers/navi-3d/app/config.py @@ -0,0 +1,68 @@ +"""Configuration and path resolution for the navi-3d MCP server. + +Paths are resolved inside ``session_files//`` when relative. +Absolute paths are accepted only if they sit inside ``FS_ALLOWED_PATHS``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +class Settings: + """Lightweight env-based settings.""" + + def __init__(self) -> None: + self.session_files_dir = Path( + os.environ.get("SESSION_FILES_DIR", "session_files") + ).resolve() + self.fs_allowed_paths = self._parse_allowed_paths( + os.environ.get("FS_ALLOWED_PATHS", "*") + ) + self.openscad = os.environ.get("OPENSCAD_PATH", "openscad") + + def _parse_allowed_paths(self, raw: str) -> list[Path]: + if raw == "*": + return [] + return [Path(p.strip()).resolve() for p in raw.split(",") if p.strip()] + + def resolve_path(self, session_id: str, raw_path: str) -> Path: + """Resolve a path that may be relative to the session directory. + + - Relative paths are resolved inside ``session_files//``. + - Absolute paths are accepted only if they are inside one of the + allowed roots (or ``*`` is set). + + Raises ``ValueError`` on anti-escape or disallowed absolute path. + """ + path = Path(raw_path).expanduser() + + if not path.is_absolute(): + session_dir = (self.session_files_dir / session_id).resolve() + path = session_dir / path + else: + path = path.resolve() + if self.fs_allowed_paths: + if not any( + path == allowed or path.is_relative_to(allowed) + for allowed in self.fs_allowed_paths + ): + raise ValueError( + f"Absolute path {path} is outside allowed paths." + ) + + # Anti-escape: the resolved path must still be inside the session dir + # or inside an allowed root. + session_dir = (self.session_files_dir / session_id).resolve() + if not (path == session_dir or path.is_relative_to(session_dir)): + if self.fs_allowed_paths: + if not any( + path == allowed or path.is_relative_to(allowed) + for allowed in self.fs_allowed_paths + ): + raise ValueError(f"Path escapes permitted directories: {path}") + else: + raise ValueError(f"Path escapes session directory: {path}") + + return path diff --git a/mcp-servers/navi-3d/app/mcp_server.py b/mcp-servers/navi-3d/app/mcp_server.py new file mode 100644 index 0000000..c70d63f --- /dev/null +++ b/mcp-servers/navi-3d/app/mcp_server.py @@ -0,0 +1,89 @@ +"""MCP server for Navi 3D modeling tools (OpenSCAD).""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from .config import Settings +from .model_compile import compile_scad +from .render_preview import render_stl +from .scad_analyze import lint_scad + +INSTRUCTIONS = """ +Navi 3D MCP server provides OpenSCAD-based modeling tools. + +Use it when the task involves: +- generating 3D models from OpenSCAD scripts (.scad → .stl); +- rendering preview images from STL files; +- linting OpenSCAD source before compilation. + +Workflow: +1. Write or read the .scad script via Navi's filesystem tool inside the + current session directory. +2. Call compile_scad to produce the STL. +3. Call lint_scad before compilation to catch common mistakes. +4. Call render_stl to generate PNG previews. +5. Use content_publish or share_file in Navi to show results to the user. + +All paths are session-scoped. Pass the exact Navi session_id so files are +resolved inside session_files//. +""".strip() + +mcp = FastMCP("navi-3d", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +def _settings() -> Settings: + return Settings() + + +@mcp.tool() +def compile_scad_tool( + session_id: Annotated[str, Field(description="Navi session ID — files are resolved inside session_files//.")], + source_path: Annotated[str, Field(description="Relative or absolute path to the .scad file.")], + output_path: Annotated[str, Field(description="Relative or absolute path for the output STL.")], +) -> str: + """Compile an OpenSCAD script into a binary STL.""" + result = compile_scad(_settings(), session_id, source_path, output_path) + return _json(result) + + +@mcp.tool() +def render_stl_tool( + session_id: Annotated[str, Field(description="Navi session ID — files are resolved inside session_files//.")], + source_path: Annotated[str, Field(description="Path to the STL file to render.")], + views: Annotated[list[str] | None, Field(description="Camera views: front, back, top, bottom, left, right, iso. Max 3.")] = None, +) -> str: + """Render preview PNG images from an STL file.""" + result = render_stl(_settings(), session_id, source_path, views or ["iso"]) + return _json(result) + + +@mcp.tool() +def lint_scad_tool( + session_id: Annotated[str, Field(description="Navi session ID — files are resolved inside session_files//.")], + source_path: Annotated[str, Field(description="Path to the .scad file.")], + strict: Annotated[bool, Field(description="Treat warnings as errors.")] = False, +) -> str: + """Lint an OpenSCAD source file before compiling.""" + result = lint_scad(_settings(), session_id, source_path, strict) + return _json(result) + + +def main() -> None: + transport = os.environ.get("NAVI_3D_MCP_TRANSPORT", "sse") + if transport not in {"stdio", "sse", "streamable-http"}: + raise SystemExit("NAVI_3D_MCP_TRANSPORT must be stdio, sse, or streamable-http") + mcp.run(transport=transport) # type: ignore[arg-type] + + +if __name__ == "__main__": + main() diff --git a/mcp-servers/navi-3d/app/model_compile.py b/mcp-servers/navi-3d/app/model_compile.py new file mode 100644 index 0000000..9f43fd2 --- /dev/null +++ b/mcp-servers/navi-3d/app/model_compile.py @@ -0,0 +1,102 @@ +"""compile_scad — generate STL from an OpenSCAD script.""" + +from __future__ import annotations + +import asyncio +import shutil +from pathlib import Path + +from .config import Settings + + +async def compile_scad( + settings: Settings, + session_id: str, + source_path: str, + output_path: str, +) -> dict: + """Compile a .scad file into a binary STL. + + Returns a dict with: + - success (bool) + - output (str) — human-readable summary + - error (str | None) + - metadata (dict) — output_path, size_kb + """ + if not shutil.which(settings.openscad): + return { + "success": False, + "output": "OpenSCAD is not installed on this system.", + "error": "openscad_not_found", + "metadata": {}, + } + + try: + scad_path = settings.resolve_path(session_id, source_path) + stl_path = settings.resolve_path(session_id, output_path) + except ValueError as exc: + return { + "success": False, + "output": str(exc), + "error": "invalid_path", + "metadata": {}, + } + + if not scad_path.exists(): + return { + "success": False, + "output": f"SCAD file not found: {scad_path}", + "error": "scad_not_found", + "metadata": {}, + } + if not scad_path.is_file(): + return { + "success": False, + "output": f"Path is not a file: {scad_path}", + "error": "not_a_file", + "metadata": {}, + } + + stl_path.parent.mkdir(parents=True, exist_ok=True) + + proc = await asyncio.create_subprocess_exec( + settings.openscad, + "--export-format", "binstl", + "-o", str(stl_path), + str(scad_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + err = (stderr.decode(errors="replace") or "OpenSCAD exited with an error.").strip() + return { + "success": False, + "output": f"OpenSCAD failed to compile STL:\n{err}", + "error": "openscad_compile_error", + "metadata": {}, + } + + if not stl_path.exists(): + return { + "success": False, + "output": "OpenSCAD completed but no STL file was produced.", + "error": "no_output", + "metadata": {}, + } + + size_kb = stl_path.stat().st_size / 1024 + return { + "success": True, + "output": ( + f"Generated: {stl_path.name}\n" + f"Path: {stl_path}\n" + f"Size: {size_kb:.1f} KB" + ), + "error": None, + "metadata": { + "output_path": str(stl_path), + "size_kb": round(size_kb, 1), + }, + } diff --git a/mcp-servers/navi-3d/app/render_preview.py b/mcp-servers/navi-3d/app/render_preview.py new file mode 100644 index 0000000..85a5323 --- /dev/null +++ b/mcp-servers/navi-3d/app/render_preview.py @@ -0,0 +1,153 @@ +"""render_stl — render preview PNGs from an STL file using OpenSCAD.""" + +from __future__ import annotations + +import asyncio +import shutil +from pathlib import Path + +from .config import Settings + +# Camera presets: (rot_x, rot_y, rot_z) +_CAMERA_PRESETS: dict[str, tuple[int, int, int]] = { + "front": (0, 0, 0), + "back": (0, 0, 180), + "top": (90, 0, 0), + "bottom": (-90, 0, 0), + "left": (0, 0, -90), + "right": (0, 0, 90), + "iso": (55, 0, 45), +} + +_IMG_W, _IMG_H = 400, 300 +_MAX_VIEWS = 3 + + +async def render_stl( + settings: Settings, + session_id: str, + source_path: str, + views: list[str] | None = None, +) -> dict: + """Render preview PNGs from an STL file. + + Returns a dict with: + - success (bool) + - output (str) — human-readable summary + - error (str | None) + - metadata (dict) — generated: list[str] + """ + if not shutil.which(settings.openscad): + return { + "success": False, + "output": "OpenSCAD is not installed on this system.", + "error": "openscad_not_found", + "metadata": {}, + } + + try: + source = settings.resolve_path(session_id, source_path) + except ValueError as exc: + return { + "success": False, + "output": str(exc), + "error": "invalid_path", + "metadata": {}, + } + + if not source.exists(): + return { + "success": False, + "output": f"STL file not found: {source}", + "error": "stl_not_found", + "metadata": {}, + } + if not source.is_file(): + return { + "success": False, + "output": f"Path is not a file: {source}", + "error": "not_a_file", + "metadata": {}, + } + + requested = views or ["iso"] + if len(requested) > _MAX_VIEWS: + return { + "success": False, + "output": f"Too many views: {len(requested)} (max {_MAX_VIEWS}).", + "error": "too_many_views", + "metadata": {}, + } + + invalid = [v for v in requested if v not in _CAMERA_PRESETS] + if invalid: + return { + "success": False, + "output": ( + f"Unknown views: {invalid}. " + f"Available: {list(_CAMERA_PRESETS.keys())}." + ), + "error": "invalid_views", + "metadata": {}, + } + + generated: list[str] = [] + errors: list[str] = [] + + for view in requested: + rot_x, rot_y, rot_z = _CAMERA_PRESETS[view] + out_png = source.with_suffix(f".{view}.png") + tmp_scad = source.with_suffix(f".{view}.tmp.scad") + tmp_scad.write_text(f'import("{source}");\n', encoding="utf-8") + + proc = await asyncio.create_subprocess_exec( + settings.openscad, + "--camera", f"0,0,0,{rot_x},{rot_y},{rot_z},500", + "--autocenter", + "--viewall", + "--imgsize", f"{_IMG_W},{_IMG_H}", + "--preview", + "-o", str(out_png), + str(tmp_scad), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + try: + tmp_scad.unlink() + except Exception: + pass + + if proc.returncode != 0: + err = stderr.decode(errors="replace").strip() or "OpenSCAD render error" + errors.append(f"{view}: {err}") + continue + + if out_png.exists(): + generated.append(str(out_png)) + else: + errors.append(f"{view}: no PNG produced") + + if errors and not generated: + return { + "success": False, + "output": "All renders failed:\n" + "\n".join(errors), + "error": "render_failed", + "metadata": {}, + } + + lines = [f"Generated {len(generated)} image(s):"] + for p in generated: + lines.append(f" {Path(p).name}") + if errors: + lines.append("\nErrors:") + for e in errors: + lines.append(f" {e}") + + return { + "success": bool(generated), + "output": "\n".join(lines), + "error": None, + "metadata": {"generated": generated}, + } diff --git a/mcp-servers/navi-3d/app/scad_analyze.py b/mcp-servers/navi-3d/app/scad_analyze.py new file mode 100644 index 0000000..ec02bd5 --- /dev/null +++ b/mcp-servers/navi-3d/app/scad_analyze.py @@ -0,0 +1,159 @@ +"""lint_scad — lightweight OpenSCAD source sanity checks.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .config import Settings + +_BUILTINS = { + "abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross", + "cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for", + "function", "hull", "if", "import", "include", "intersection", "len", "let", "ln", + "log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron", + "pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere", + "sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use", +} +_NAMED_ARGS = { + "center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs", +} +_NOISY_PATTERNS = ( + r"\b[Ll]et'?s\b", + r"\bActually\b", + r"\bWait\b", + r"\bhacky\b", + r"\bCorrected implementation\b", + r"\bFINAL[, ]+FINAL\b", + r"\bI promise\b", + r"\boverthinking\b", +) + + +async def lint_scad( + settings: Settings, + session_id: str, + source_path: str, + strict: bool = False, +) -> dict: + """Lint a .scad file. Returns a dict with success, output, error, metadata.""" + try: + path = settings.resolve_path(session_id, source_path) + except ValueError as exc: + return { + "success": False, + "output": str(exc), + "error": "invalid_path", + "metadata": {}, + } + + if not path.exists(): + return { + "success": False, + "output": f"SCAD file not found: {path}", + "error": "scad_not_found", + "metadata": {}, + } + if not path.is_file(): + return { + "success": False, + "output": f"Path is not a file: {path}", + "error": "not_a_file", + "metadata": {}, + } + if path.suffix.lower() != ".scad": + return { + "success": False, + "output": f"Expected a .scad file: {path}", + "error": "not_scad", + "metadata": {}, + } + + source = path.read_text(encoding="utf-8", errors="replace") + errors, warnings = _lint_scad_source(source) + failed = bool(errors or (strict and warnings)) + + lines = [f"SCAD lint: {path.name}"] + if not errors and not warnings: + lines.append("OK: no issues found.") + else: + if errors: + lines.append(f"Errors ({len(errors)}):") + lines.extend(f"- {item}" for item in errors) + if warnings: + label = "Warnings as errors" if strict else "Warnings" + lines.append(f"{label} ({len(warnings)}):") + lines.extend(f"- {item}" for item in warnings) + + return { + "success": not failed, + "output": "\n".join(lines), + "error": "scad_lint_failed" if failed else None, + "metadata": {"errors": errors, "warnings": warnings}, + } + + +def _lint_scad_source(source: str) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + code = _strip_comments_and_strings(source) + + _check_noisy_source(source, errors) + _check_undefined_identifiers(code, errors) + _check_abandoned_modules(code, warnings) + _check_suspicious_geometry(source, warnings) + + return errors, warnings + + +def _strip_comments_and_strings(source: str) -> str: + source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S) + source = re.sub(r"//.*", " ", source) + source = re.sub(r'"(?:\\.|[^"\\])*"', '""', source) + return source + + +def _check_noisy_source(source: str, errors: list[str]) -> None: + for pattern in _NOISY_PATTERNS: + if re.search(pattern, source): + errors.append( + "Source contains draft/self-correction language " + f"matching `{pattern}`. Rewrite as clean production OpenSCAD." + ) + + +def _check_undefined_identifiers(code: str, errors: list[str]) -> None: + declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code)) + modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)) + functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code)) + loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code)) + module_params = set() + for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code): + module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params)) + + known = declared | modules | functions | loop_vars | module_params | _BUILTINS | _NAMED_ARGS + tokens = set(re.findall(r"\b[A-Za-z_]\w*\b", code)) + unknown = sorted(t for t in tokens if t not in known) + if unknown: + errors.append("Unknown identifier(s): " + ", ".join(unknown[:20])) + + +def _check_abandoned_modules(code: str, warnings: list[str]) -> None: + modules = re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code) + for name in modules: + uses = len(re.findall(rf"\b{re.escape(name)}\s*\(", code)) + if uses <= 1: + warnings.append(f"Module `{name}` is defined but never called.") + + +def _check_suspicious_geometry(source: str, warnings: list[str]) -> None: + if re.search(r"\bcube\s*\(\s*\[\s*[^]]*\+\s*chamfer_size\s*\*\s*2", source): + warnings.append( + "Suspicious chamfer construction: subtracting an oversized cube often slices " + "off a cap instead of creating edge chamfers." + ) + if re.search(r"\bhole_diameter\s*/\s*2\b", source): + warnings.append( + "`hole_diameter / 2` used in geometry. If the OpenSCAD parameter is named " + "`hole_diameter`, cylinder holes should usually use `d=hole_diameter`." + ) diff --git a/mcp-servers/navi-3d/pyproject.toml b/mcp-servers/navi-3d/pyproject.toml new file mode 100644 index 0000000..0cc3250 --- /dev/null +++ b/mcp-servers/navi-3d/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-navi-3d" +version = "0.1.0" +description = "MCP server for Navi 3D modeling tools (OpenSCAD)" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0.0", + "pydantic>=2.0.0", + "pyyaml>=6.0", +] + +[project.scripts] +mcp-server-navi-3d = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py b/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py new file mode 100644 index 0000000..70960f5 --- /dev/null +++ b/mcp-servers/navi-3d/tests/unit/test_scad_analyze.py @@ -0,0 +1,32 @@ +"""Unit tests for scad_analyze module.""" + +from app.scad_analyze import _lint_scad_source + + +def test_lint_clean_source(): + source = "cube([10, 10, 10]);\n" + errors, warnings = _lint_scad_source(source) + assert not errors + assert not warnings + + +def test_lint_undefined_identifier(): + source = "cube([foo, 10, 10]);\n" + errors, warnings = _lint_scad_source(source) + assert errors + assert "Unknown identifier(s): foo" in errors[0] + + +def test_lint_noisy_source(): + source = "// Actually, let's fix this\ncube([10,10,10]);\n" + errors, warnings = _lint_scad_source(source) + assert errors + assert "draft/self-correction language" in errors[0] + + +def test_lint_abandoned_module(): + source = "module unused() { cube([1,1,1]); }\n" + errors, warnings = _lint_scad_source(source) + assert not errors + assert warnings + assert "unused` is defined but never called" in warnings[0] diff --git a/mcp_servers.json b/mcp_servers.json index f552db9..73281e9 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -27,5 +27,19 @@ ] }, "instructions": "MANDATORY for profiles that expose gnexus-book tools: Before answering any question about infrastructure, servers, services, networks, documentation, or system inventory, call gnexus-book tools first.\n\nUse only gnexus-book tool names that are present in the current tool schema. In Navi they are exposed with the mcp_gnexus-book_ prefix, but each profile may expose only some groups. Do not invent or call gnexus-book tools that are not in the current tool list.\n\nQuery mapping by capability:\n- Status or facts about a server/service → search docs first, then read a specific doc or inventory item if those tools are available.\n- Service placement or topology → list inventory and relationships if available.\n- Documentation changes → read the target doc first, then propose a doc or inventory change if write tools are available.\n- Freshness questions → use freshness checks if available.\n- Repository validation/status → use repository tools only if they are available in the current tool schema; otherwise skip this step and continue with available read/write tools.\n\nDo not rely on memory for infrastructure facts. Memory is only for personal user facts and preferences. Always pull infrastructure state from gnexus-book when these tools are available to the active profile.\n\nDo not store raw secrets in documentation.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write gnexus-book files. The MCP tools are the ONLY valid interface to this knowledge base. Violating this rule bypasses validation, corrupts repository state, and breaks consistency guarantees.\n- To read: use mcp_gnexus-book_search_docs, mcp_gnexus-book_read_doc, mcp_gnexus-book_list_inventory, mcp_gnexus-book_get_inventory_item.\n- To write: use mcp_gnexus-book_propose_doc_change, mcp_gnexus-book_propose_inventory_item_change, mcp_gnexus-book_apply_pending_change, mcp_gnexus-book_commit_changes.\n- NEVER call filesystem write, filesystem smart_edit, terminal, or code_exec on gnexus-book paths.\n\nBefore the final response, decide whether tool execution revealed stable reusable infrastructure facts, service configurations, or relationships. If yes and gnexus-book write tools are available, persist them before answering. If write tools are not available, report the facts that should be persisted. If the fact is user-specific rather than infrastructure documentation, use the memory tool instead. Choose the target based on scope, not habit." + }, + "navi-3d": { + "transport": "sse", + "url": "http://localhost:8002/sse", + "groups": { + "modeling": [ + "compile_scad", + "render_stl" + ], + "analysis": [ + "lint_scad" + ] + }, + "instructions": "Navi 3D MCP server provides OpenSCAD-based 3D modeling tools.\n\nUse it when the task involves generating 3D models, rendering previews, or linting OpenSCAD source.\n\nWorkflow:\n1. Write the .scad script to the current session directory via filesystem.\n2. Call lint_scad first to catch common mistakes.\n3. Call compile_scad to produce the STL.\n4. Call render_stl to generate PNG previews.\n5. Use content_publish or share_file to show results to the user.\n\nAll paths are session-scoped. Pass the exact Navi session_id.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write 3D model files that belong to the navi-3d knowledge base. Use only the MCP tools listed above." } } \ No newline at end of file diff --git a/navi/core/registry.py b/navi/core/registry.py index a7b24d5..14a0903 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -15,7 +15,6 @@ ListProfilesTool, MemoryTool, ReflectTool, - ScadLintTool, SpawnAgentTool, SshExecTool, ScratchpadTool, @@ -33,8 +32,6 @@ from navi.tools.write_tool import WriteToolTool from navi.tools.share_file import ShareFileTool from navi.tools.content_publish import ContentPublishTool -from navi.tools.model_3d import Model3DTool -from navi.tools.render_3d import Render3DTool from navi.tools.mcp_status import McpStatusTool from navi.tools.loader import LoadResult, load_tools_from_dir from navi.tools.logging_middleware import LoggingMiddleware @@ -197,9 +194,8 @@ memory_tool = MemoryTool(memory_store) if memory_store else None mcp_status_tool = McpStatusTool() builtins = [WebSearchTool(), FilesystemTool(ai_helper=ai_helper), HttpRequestTool(), WebViewTool(), - CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ScadLintTool(), + CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ShareFileTool(), ContentPublishTool(), TestToolTool(), - Model3DTool(), Render3DTool(), TodoTool(), ScratchpadTool(), ReflectTool(ai_helper=ai_helper), reload_tool, write_tool, delete_tool, list_tool, manual_tool, mcp_status_tool] if memory_tool: diff --git a/navi/mcp/client.py b/navi/mcp/client.py index 0ac823e..0a94267 100644 --- a/navi/mcp/client.py +++ b/navi/mcp/client.py @@ -128,9 +128,12 @@ result = await self._session.list_tools() return list(result.tools) - async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> str: + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> tuple[str, bool]: """Execute a remote tool and return its output as a string. + Returns (output_text, is_error). ``is_error`` comes from the MCP + ``CallToolResult.isError`` field so the caller can set ``success=False``. + Text content is concatenated; images are reported as a placeholder. """ await self._ensure_connected() @@ -150,4 +153,5 @@ else: parts.append(f"[{item.type}]") - return "\n".join(parts) + is_error = getattr(result, "isError", False) + return "\n".join(parts), is_error diff --git a/navi/mcp/manager.py b/navi/mcp/manager.py index dd6a6e7..16c4158 100644 --- a/navi/mcp/manager.py +++ b/navi/mcp/manager.py @@ -125,8 +125,12 @@ out[name] = "\n".join(parts) return out - async def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any] | None = None) -> str: - """Proxy a tool call to the named server.""" + async def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any] | None = None) -> tuple[str, bool]: + """Proxy a tool call to the named server. + + Returns (output_text, is_error) so the caller knows whether the MCP + tool itself reported a failure. + """ client = self._clients.get(server_name) if client is None: raise RuntimeError(f"MCP server {server_name!r} is not connected") diff --git a/navi/mcp/tools.py b/navi/mcp/tools.py index 46ca1b3..b938894 100644 --- a/navi/mcp/tools.py +++ b/navi/mcp/tools.py @@ -31,9 +31,15 @@ async def execute(self, params: dict[str, Any]) -> ToolResult: try: - output = await self._manager.call_tool( + output, is_error = await self._manager.call_tool( self.server_name, self.tool_name, params ) + if is_error: + return ToolResult( + success=False, + output=output, + error="MCP tool reported an error", + ) return ToolResult(success=True, output=output) except Exception as exc: return ToolResult(success=False, output="", error=str(exc)) diff --git a/navi/profiles/modeler_3d/config.json b/navi/profiles/modeler_3d/config.json index adf475e..b31dc6e 100644 --- a/navi/profiles/modeler_3d/config.json +++ b/navi/profiles/modeler_3d/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "Physically coherent 3D geometry and STL generation. Generates STL files from OpenSCAD through dedicated 3D tools, and validates with OpenSCAD compilation plus preview render inspection.", "when_to_use": "When the user needs a physical object modeled as 3D geometry: replacement parts, mechanical assemblies, decorative items, functional prototypes, jigs, fixtures, or custom enclosures.", - "key_tools": "spawn_agent, filesystem, scad_lint, model_3d, render_3d, image_view, content_publish" + "key_tools": "spawn_agent, filesystem, mcp_navi-3d_lint_scad, mcp_navi-3d_compile_scad, mcp_navi-3d_render_stl, image_view, content_publish" }, "llm_backend": "ollama", "model": [ @@ -49,16 +49,16 @@ "code_exec", "terminal", "image_view", - "scad_lint", "memory", "list_tools", "tool_manual", "spawn_agent", "share_file", - "content_publish", - "model_3d", - "render_3d" + "content_publish" ], + "mcp_servers": { + "navi-3d": ["modeling", "analysis"] + }, "top_k": 30, "top_p": 0.85, "num_thread": 11 diff --git a/navi/profiles/modeler_3d/system_prompt.txt b/navi/profiles/modeler_3d/system_prompt.txt index 6f5d657..d9e56c2 100644 --- a/navi/profiles/modeler_3d/system_prompt.txt +++ b/navi/profiles/modeler_3d/system_prompt.txt @@ -24,10 +24,12 @@ Use the dedicated 3D tools and use OpenSCAD as the only geometry generator. +All MCP 3D tools require the exact Navi `session_id` so files are resolved inside `session_files//`. Pass the current session ID from context. + 1. **`filesystem write`** — write the OpenSCAD script (`.scad`) to the session directory. -2. **`scad_lint`** — check the `.scad` source for common LLM mistakes before compiling. -3. **`model_3d`** — compile the `.scad` into a binary `.stl`. -4. **`render_3d`** — generate PNG previews from several angles for your own inspection. +2. **`mcp_navi-3d_lint_scad`** — check the `.scad` source for common LLM mistakes before compiling. +3. **`mcp_navi-3d_compile_scad`** — compile the `.scad` into a binary `.stl`. +4. **`mcp_navi-3d_render_stl`** — generate PNG previews from several angles for your own inspection. 5. **`image_view`** — inspect each PNG so YOU can verify geometry. PNG previews are for Navi, not for the user. 6. **`content_publish`** — publish the final STL after internal checks pass. Include `source_filename` when a real `.scad` source exists in the same session directory. @@ -140,8 +142,8 @@ When the next step requires a tool, call the tool in the same assistant turn. Do not end a message with an announcement such as "I will now compile", "I am moving to publication", or "Next I will publish" unless you also make the required tool call in that same turn. - If the STL is ready to publish, call `content_publish` immediately instead of saying you are going to publish it. -- If the SCAD is ready to compile, call `model_3d` immediately instead of saying you are going to compile it. -- If the SCAD was just written or edited, call `scad_lint` immediately before `model_3d`. +- If the SCAD is ready to compile, call `mcp_navi-3d_compile_scad` immediately instead of saying you are going to compile it. +- If the SCAD was just written or edited, call `mcp_navi-3d_lint_scad` immediately before `mcp_navi-3d_compile_scad`. - If previews are ready to inspect, call `image_view` immediately instead of saying you are going to inspect them. - Only send a text-only progress message when you are blocked, need a user decision, or are reporting a completed tool result. @@ -152,7 +154,7 @@ - Never type, shorten, or reconstruct session IDs from memory. - Use the exact `Session files directory` from system context or from tool output. - Keep the `.scad`, compiled `.stl`, and preview PNGs in that same current session directory. -- Prefer simple filenames with `model_3d` when possible; the tool resolves them inside the current session directory. +- Prefer simple filenames with `mcp_navi-3d_compile_scad` when possible; the tool resolves them inside the current session directory. - Choose the final basename before compiling, for example `wind_turbine_blades_100mm.scad` and `wind_turbine_blades_100mm.stl`. Avoid renaming after compilation unless necessary. - If `content_publish` returns `not_found`, read the exact directory named in its error, list that directory, then copy/move or recreate the file there before retrying. Do not keep listing or editing another `session_files/` directory. @@ -166,9 +168,9 @@ 6. **Lint OpenSCAD** — call `scad_lint(path="...scad")` after the file exists. Fix every error before compiling. Treat warnings as reasons to inspect and revise when they affect geometry or source cleanliness. 7. **Research missing facts when useful** — if exact dimensions, reference images, or local documents are needed, call `spawn_agent` with a narrow research brief. Use the returned facts to update `technical_spec` or `design_plan`; do not ask the subagent to design, write, review, compile, render, or publish the model. 8. **Compile STL** — call `model_3d(scad_path=..., output_path=...)`. -9. **Handle compile result** — proceed only if `model_3d` returns success. If it returns `openscad_compile_error`, `no_output`, `scad_not_found`, `wrong_session_dir`, or another error, fix the cause and compile again. +9. **Handle compile result** — proceed only if `mcp_navi-3d_compile_scad` returns success. If it returns `openscad_compile_error`, `no_output`, `scad_not_found`, `wrong_session_dir`, or another error, fix the cause and compile again. 10. **Render previews** — call `render_3d(source="...stl", views=["iso","front","top"])` or other relevant views. -11. **Inspect every preview** — call `image_view` on every PNG path returned by `render_3d`. Do not publish PNG previews unless the user explicitly asks for preview images. +11. **Inspect every preview** — call `image_view` on every PNG path returned by `mcp_navi-3d_render_stl`. Do not publish PNG previews unless the user explicitly asks for preview images. 12. **Run preview checklist** — compare all inspected previews against `technical_spec` and `design_plan`. Record the checklist result in `scratchpad` section `preview_check`. 13. **Revise before publishing** — if lint, compilation output, researched facts, or preview inspection reveals a substantial issue, edit the `.scad`, lint again, recompile, re-render, and inspect again. 14. **Publish final STL** — after the model passes the geometry gate, call `content_publish(filename="...stl", content_type="stl", source_filename="...scad")`. This step is mandatory for a successful task. @@ -204,10 +206,10 @@ - Functional, mechanical, and parametric fit parts have a `scratchpad` `parameter_sanity_check` confirming interface dimensions and formulas are internally consistent. - The final `.stl` and `.scad` source are in the current session files directory, not another `session_files/` directory. - The STL compiled successfully. -- Every preview image returned by `render_3d` was inspected with `image_view`; inspecting only `iso` is not enough. +- Every preview image returned by `mcp_navi-3d_render_stl` was inspected with `image_view`; inspecting only `iso` is not enough. - `scratchpad` section `preview_check` exists and says `Revision required: no`, or records the revision that was made after a failed check. -- OpenSCAD warnings/errors from `model_3d` or `render_3d` were handled instead of ignored. -- `scad_lint` was run on the final `.scad`, and all lint errors were fixed before compilation. +- OpenSCAD warnings/errors from `mcp_navi-3d_compile_scad` or `mcp_navi-3d_render_stl` were handled instead of ignored. +- `mcp_navi-3d_lint_scad` was run on the final `.scad`, and all lint errors were fixed before compilation. - Any subagent use was limited to gathering missing factual information from web pages, local files, or images. The main agent made all modeling decisions. - For functional fit parts, exact compatibility dimensions are either verified, provided by the user, or the artifact is explicitly labeled as a parametric template that requires user measurements. diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 541741a..3296243 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -4,7 +4,6 @@ from .filesystem import FilesystemTool from .http_request import HttpRequestTool from .image_view import ImageViewTool -from .scad_lint import ScadLintTool from .ssh_exec import SshExecTool from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool @@ -29,7 +28,6 @@ "TerminalTool", "SshExecTool", "ImageViewTool", - "ScadLintTool", "WebViewTool", "MemoryTool", "TestToolTool", diff --git a/navi/tools/model_3d.py b/navi/tools/model_3d.py deleted file mode 100644 index 913c211..0000000 --- a/navi/tools/model_3d.py +++ /dev/null @@ -1,152 +0,0 @@ -"""model_3d — generate an STL file from an OpenSCAD script. - -Requires OpenSCAD installed on the system: - Arch: sudo pacman -S openscad - Debian: sudo apt install openscad -""" - -import asyncio -import shutil -from pathlib import Path - -from navi.config import settings -from navi.session_files import session_dir - -from .base import Tool, ToolResult, current_session_id - - -class Model3DTool(Tool): - name = "model_3d" - description = ( - "Generate a binary STL file from an existing OpenSCAD (.scad) script. " - "The script must already be written to disk; this tool only compiles it.\n\n" - "Workflow:\n" - "1. Write your .scad script with the filesystem tool.\n" - "2. Call model_3d with the scad_path and desired output_path.\n" - "3. The resulting STL is saved to output_path.\n\n" - "Session files rule: if you use session_files//..., use the exact current " - "session directory from context or tool output. Do not reconstruct or retype session IDs. " - "Simple filenames without directories are resolved inside the current session directory.\n\n" - "Use content_publish on the STL to show the user an interactive 3D viewer." - ) - parameters = { - "type": "object", - "properties": { - "scad_path": { - "type": "string", - "description": ( - "Absolute or relative path to the existing .scad file. " - "The file must already exist." - ), - }, - "output_path": { - "type": "string", - "description": ( - "Absolute or relative path where the STL should be written. " - "Parent directories are created automatically." - ), - }, - }, - "required": ["scad_path", "output_path"], - } - - async def execute(self, params: dict) -> ToolResult: - session_id = current_session_id.get() - scad_path = self._resolve_model_path(params["scad_path"], session_id) - output_path = self._resolve_model_path(params["output_path"], session_id) - - session_error = self._validate_current_session_path(scad_path, "scad_path", session_id) - if session_error: - return session_error - session_error = self._validate_current_session_path(output_path, "output_path", session_id) - if session_error: - return session_error - - if not scad_path.exists(): - return ToolResult( - success=False, - output=f"SCAD file not found: {scad_path}", - error="scad_not_found", - ) - if not scad_path.is_file(): - return ToolResult( - success=False, - output=f"Path is not a file: {scad_path}", - error="not_a_file", - ) - - if not shutil.which("openscad"): - return ToolResult( - success=False, - output="OpenSCAD is not installed on this system.", - error="openscad_not_found", - ) - - output_path.parent.mkdir(parents=True, exist_ok=True) - - proc = await asyncio.create_subprocess_exec( - "openscad", - "--export-format", "binstl", - "-o", str(output_path), - str(scad_path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - err = (stderr.decode(errors="replace") or "OpenSCAD exited with an error.").strip() - return ToolResult( - success=False, - output=f"OpenSCAD failed to compile STL:\n{err}", - error="openscad_compile_error", - ) - - if not output_path.exists(): - return ToolResult( - success=False, - output="OpenSCAD completed but no STL file was produced.", - error="no_output", - ) - - size_kb = output_path.stat().st_size / 1024 - return ToolResult( - success=True, - output=( - f"Generated: {output_path.name}\n" - f"Path: {output_path}\n" - f"Size: {size_kb:.1f} KB" - ), - metadata={"output_path": str(output_path), "size_kb": round(size_kb, 1)}, - ) - - @staticmethod - def _resolve_model_path(raw_path: str, session_id: str | None) -> Path: - path = Path(raw_path).expanduser() - if session_id and not path.is_absolute() and len(path.parts) == 1: - return (session_dir(session_id) / path).resolve() - return path.resolve() - - @staticmethod - def _validate_current_session_path( - path: Path, - param_name: str, - session_id: str | None, - ) -> ToolResult | None: - if not session_id: - return None - - session_root = Path(settings.session_files_dir).resolve() - current_dir = session_dir(session_id).resolve() - if path.is_relative_to(session_root) and not path.is_relative_to(current_dir): - return ToolResult( - success=False, - output=( - f"{param_name} points to a different session directory: {path}\n" - f"Current session directory is: {current_dir}\n" - "Use the exact current session directory. Do not manually reconstruct " - "or retype session IDs." - ), - error="wrong_session_dir", - ) - return None diff --git a/navi/tools/render_3d.py b/navi/tools/render_3d.py deleted file mode 100644 index 69cfc33..0000000 --- a/navi/tools/render_3d.py +++ /dev/null @@ -1,160 +0,0 @@ -"""render_3d — render preview images from an STL file using OpenSCAD. - -Requires OpenSCAD installed on the system: - Arch: sudo pacman -S openscad - Debian: sudo apt install openscad -""" - -import asyncio -import shutil -from pathlib import Path - -from .base import Tool, ToolResult - -# Camera presets: (rot_x, rot_y, rot_z) -_CAMERA_PRESETS: dict[str, tuple[int, int, int]] = { - "front": (0, 0, 0), - "back": (0, 0, 180), - "top": (90, 0, 0), - "bottom": (-90, 0, 0), - "left": (0, 0, -90), - "right": (0, 0, 90), - "iso": (55, 0, 45), -} - -_IMG_W, _IMG_H = 400, 300 -_MAX_VIEWS = 3 - - -class Render3DTool(Tool): - name = "render_3d" - description = ( - "Render preview images from an STL file using OpenSCAD. " - "Produces one PNG per requested view.\n\n" - "Workflow:\n" - "1. Generate the STL with model_3d.\n" - "2. Call render_3d with the STL path and desired views.\n" - "3. PNG files are saved next to the STL with view suffixes.\n" - "4. Inspect PNG previews with image_view before publishing the final 3D model. " - "PNG previews are usually internal QA artifacts, not user-facing deliverables.\n\n" - "Available views: front, back, top, bottom, left, right, iso." - ) - parameters = { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "Path to the STL file to render. Must exist.", - }, - "views": { - "type": "array", - "items": {"type": "string", "enum": list(_CAMERA_PRESETS.keys())}, - "description": ( - "List of camera views to render. " - f"Maximum {_MAX_VIEWS} views per call. " - "Default: [\"iso\"]" - ), - }, - }, - "required": ["source"], - } - - async def execute(self, params: dict) -> ToolResult: - if not shutil.which("openscad"): - return ToolResult( - success=False, - output="OpenSCAD is not installed on this system.", - error="openscad_not_found", - ) - - source = Path(params["source"]).expanduser().resolve() - if not source.exists(): - return ToolResult( - success=False, - output=f"STL file not found: {source}", - error="stl_not_found", - ) - if not source.is_file(): - return ToolResult( - success=False, - output=f"Path is not a file: {source}", - error="not_a_file", - ) - - views = params.get("views") or ["iso"] - if len(views) > _MAX_VIEWS: - return ToolResult( - success=False, - output=f"Too many views: {len(views)} (max {_MAX_VIEWS}).", - error="too_many_views", - ) - - invalid = [v for v in views if v not in _CAMERA_PRESETS] - if invalid: - return ToolResult( - success=False, - output=f"Unknown views: {invalid}. Available: {list(_CAMERA_PRESETS.keys())}.", - error="invalid_views", - ) - - generated: list[str] = [] - errors: list[str] = [] - - for view in views: - rot_x, rot_y, rot_z = _CAMERA_PRESETS[view] - out_png = source.with_suffix(f".{view}.png") - - # Build a temporary SCAD that imports the STL - tmp_scad = source.with_suffix(f".{view}.tmp.scad") - tmp_scad.write_text(f'import("{source}");\n', encoding="utf-8") - - proc = await asyncio.create_subprocess_exec( - "openscad", - "--camera", f"0,0,0,{rot_x},{rot_y},{rot_z},500", - "--autocenter", - "--viewall", - "--imgsize", f"{_IMG_W},{_IMG_H}", - "--preview", - "-o", str(out_png), - str(tmp_scad), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - - # Clean up temp SCAD immediately - try: - tmp_scad.unlink() - except Exception: - pass - - if proc.returncode != 0: - err = stderr.decode(errors="replace").strip() or "OpenSCAD render error" - errors.append(f"{view}: {err}") - continue - - if out_png.exists(): - generated.append(str(out_png)) - else: - errors.append(f"{view}: no PNG produced") - - if errors and not generated: - return ToolResult( - success=False, - output="All renders failed:\n" + "\n".join(errors), - error="render_failed", - ) - - lines = [f"Generated {len(generated)} image(s):"] - for p in generated: - lines.append(f" {Path(p).name}") - if errors: - lines.append("\nErrors:") - for e in errors: - lines.append(f" {e}") - - return ToolResult( - success=bool(generated), - output="\n".join(lines), - metadata={"generated": generated}, - ) diff --git a/navi/tools/scad_lint.py b/navi/tools/scad_lint.py deleted file mode 100644 index 36c6263..0000000 --- a/navi/tools/scad_lint.py +++ /dev/null @@ -1,198 +0,0 @@ -"""scad_lint - lightweight OpenSCAD source sanity checks.""" - -from __future__ import annotations - -import re -from pathlib import Path - -from navi.config import settings -from navi.session_files import session_dir - -from .base import Tool, ToolResult, current_session_id - - -_BUILTINS = { - "abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross", - "cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for", - "function", "hull", "if", "import", "include", "intersection", "len", "let", "ln", - "log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron", - "pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere", - "sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use", -} -_NAMED_ARGS = { - "center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs", -} -_NOISY_PATTERNS = ( - r"\b[Ll]et'?s\b", - r"\bActually\b", - r"\bWait\b", - r"\bhacky\b", - r"\bCorrected implementation\b", - r"\bFINAL[, ]+FINAL\b", - r"\bI promise\b", - r"\boverthinking\b", -) - - -class ScadLintTool(Tool): - name = "scad_lint" - description = ( - "Lint an OpenSCAD (.scad) source file before compiling or publishing. " - "This is a lightweight sanity check, not a full CAD validator. It catches common " - "LLM mistakes: abandoned modules, undefined identifiers, draft/self-correction " - "comments, suspicious chamfer hacks, and parameter inconsistencies. " - "Run it after writing or editing .scad and fix errors before model_3d." - ) - parameters = { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": ( - "Absolute or relative path to the .scad file. Simple filenames are " - "resolved inside the current session directory." - ), - }, - "strict": { - "type": "boolean", - "description": "When true, warnings also make the tool fail. Default false.", - }, - }, - "required": ["path"], - } - - async def execute(self, params: dict) -> ToolResult: - session_id = current_session_id.get() - path = self._resolve_path(params["path"], session_id) - session_error = self._validate_current_session_path(path, session_id) - if session_error: - return session_error - - if not path.exists(): - return ToolResult(False, f"SCAD file not found: {path}", error="scad_not_found") - if not path.is_file(): - return ToolResult(False, f"Path is not a file: {path}", error="not_a_file") - if path.suffix.lower() != ".scad": - return ToolResult(False, f"Expected a .scad file: {path}", error="not_scad") - - source = path.read_text(encoding="utf-8", errors="replace") - errors, warnings = lint_scad_source(source) - strict = bool(params.get("strict", False)) - failed = bool(errors or (strict and warnings)) - - output = self._format_output(path, errors, warnings, strict) - return ToolResult( - success=not failed, - output=output, - error="scad_lint_failed" if failed else None, - metadata={"errors": errors, "warnings": warnings}, - ) - - @staticmethod - def _resolve_path(raw_path: str, session_id: str | None) -> Path: - path = Path(raw_path).expanduser() - if session_id and not path.is_absolute() and len(path.parts) == 1: - return (session_dir(session_id) / path).resolve() - return path.resolve() - - @staticmethod - def _validate_current_session_path(path: Path, session_id: str | None) -> ToolResult | None: - if not session_id: - return None - - session_root = Path(settings.session_files_dir).resolve() - current_dir = session_dir(session_id).resolve() - if path.is_relative_to(session_root) and not path.is_relative_to(current_dir): - return ToolResult( - False, - ( - f"path points to a different session directory: {path}\n" - f"Current session directory is: {current_dir}\n" - "Use the exact current session directory. Do not manually reconstruct " - "or retype session IDs." - ), - error="wrong_session_dir", - ) - return None - - @staticmethod - def _format_output(path: Path, errors: list[str], warnings: list[str], strict: bool) -> str: - lines = [f"SCAD lint: {path.name}"] - if not errors and not warnings: - lines.append("OK: no issues found.") - return "\n".join(lines) - - if errors: - lines.append(f"Errors ({len(errors)}):") - lines.extend(f"- {item}" for item in errors) - if warnings: - label = "Warnings as errors" if strict else "Warnings" - lines.append(f"{label} ({len(warnings)}):") - lines.extend(f"- {item}" for item in warnings) - return "\n".join(lines) - - -def lint_scad_source(source: str) -> tuple[list[str], list[str]]: - errors: list[str] = [] - warnings: list[str] = [] - code = _strip_comments_and_strings(source) - - _check_noisy_source(source, errors) - _check_undefined_identifiers(code, errors) - _check_abandoned_modules(code, warnings) - _check_suspicious_geometry(source, warnings) - - return errors, warnings - - -def _strip_comments_and_strings(source: str) -> str: - source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S) - source = re.sub(r"//.*", " ", source) - source = re.sub(r'"(?:\\.|[^"\\])*"', '""', source) - return source - - -def _check_noisy_source(source: str, errors: list[str]) -> None: - for pattern in _NOISY_PATTERNS: - if re.search(pattern, source): - errors.append( - "Source contains draft/self-correction language " - f"matching `{pattern}`. Rewrite as clean production OpenSCAD." - ) - - -def _check_undefined_identifiers(code: str, errors: list[str]) -> None: - declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code)) - modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)) - functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code)) - loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code)) - module_params = set() - for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code): - module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params)) - - known = declared | modules | functions | loop_vars | module_params | _BUILTINS | _NAMED_ARGS - tokens = set(re.findall(r"\b[A-Za-z_]\w*\b", code)) - unknown = sorted(t for t in tokens if t not in known) - if unknown: - errors.append("Unknown identifier(s): " + ", ".join(unknown[:20])) - - -def _check_abandoned_modules(code: str, warnings: list[str]) -> None: - modules = re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code) - for name in modules: - uses = len(re.findall(rf"\b{re.escape(name)}\s*\(", code)) - if uses <= 1: - warnings.append(f"Module `{name}` is defined but never called.") - - -def _check_suspicious_geometry(source: str, warnings: list[str]) -> None: - if re.search(r"\bcube\s*\(\s*\[\s*[^]]*\+\s*chamfer_size\s*\*\s*2", source): - warnings.append( - "Suspicious chamfer construction: subtracting an oversized cube often slices " - "off a cap instead of creating edge chamfers." - ) - if re.search(r"\bhole_diameter\s*/\s*2\b", source): - warnings.append( - "`hole_diameter / 2` used in geometry. If the OpenSCAD parameter is named " - "`hole_diameter`, cylinder holes should usually use `d=hole_diameter`." - ) diff --git a/pyproject.toml b/pyproject.toml index 19b73f8..a425df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,3 +58,4 @@ [tool.pytest.ini_options] asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/tests/unit/test_mcp.py b/tests/unit/test_mcp.py index 040fd2e..86f6530 100644 --- a/tests/unit/test_mcp.py +++ b/tests/unit/test_mcp.py @@ -109,7 +109,7 @@ async def test_execute_success(self): mock_manager = AsyncMock(spec=McpManager) - mock_manager.call_tool.return_value = "found 3 results" + mock_manager.call_tool.return_value = ("found 3 results", False) tool = McpTool( server_name="book", tool_name="search", @@ -122,6 +122,21 @@ assert result.output == "found 3 results" mock_manager.call_tool.assert_awaited_once_with("book", "search", {"query": "foo"}) + async def test_execute_mcp_error(self): + mock_manager = AsyncMock(spec=McpManager) + mock_manager.call_tool.return_value = ("validation failed", True) + tool = McpTool( + server_name="book", + tool_name="apply", + description="", + parameters={}, + manager=mock_manager, + ) + result = await tool.execute({}) + assert not result.success + assert result.output == "validation failed" + assert "MCP tool reported an error" in result.error + async def test_execute_failure(self): mock_manager = AsyncMock(spec=McpManager) mock_manager.call_tool.side_effect = RuntimeError("server down") diff --git a/tests/unit/tools/test_scad_lint.py b/tests/unit/tools/test_scad_lint.py deleted file mode 100644 index d4d57f3..0000000 --- a/tests/unit/tools/test_scad_lint.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Unit tests for scad_lint tool.""" - -import pytest - -import navi.tools.scad_lint as scad_lint_mod -from navi.tools.base import current_session_id -from navi.tools.scad_lint import ScadLintTool, lint_scad_source - - -def test_lint_detects_draft_language_unknown_identifier_and_abandoned_module(): - source = """ -module unused() { - cube([1, 1, 1]); -} - -// Actually, let's use a corrected implementation. -module final_model() { - cube([pad_recss_width, 2, 3]); -} - -final_model(); -""" - - errors, warnings = lint_scad_source(source) - - assert any("draft/self-correction" in item for item in errors) - assert any("pad_recss_width" in item for item in errors) - assert any("unused" in item for item in warnings) - - -def test_lint_detects_hole_diameter_division_warning(): - source = """ -hole_diameter = 3.2; -cylinder(d = hole_diameter / 2, h = 5); -""" - - errors, warnings = lint_scad_source(source) - - assert errors == [] - assert any("hole_diameter / 2" in item for item in warnings) - - -class TestScadLintTool: - @pytest.fixture - def tool(self, monkeypatch, tmp_path): - monkeypatch.setattr(scad_lint_mod.settings, "session_files_dir", str(tmp_path / "sessions")) - token = current_session_id.set("sess-1") - try: - yield ScadLintTool() - finally: - current_session_id.reset(token) - - async def test_resolves_simple_filename_inside_session_dir(self, tool, tmp_path): - sess_dir = tmp_path / "sessions" / "sess-1" - sess_dir.mkdir(parents=True) - (sess_dir / "ok.scad").write_text("cube([1, 1, 1]);") - - result = await tool.execute({"path": "ok.scad"}) - - assert result.success - assert "OK" in result.output - - async def test_rejects_wrong_session_dir(self, tool, tmp_path): - other_dir = tmp_path / "sessions" / "sess-2" - other_dir.mkdir(parents=True) - other = other_dir / "model.scad" - other.write_text("cube([1, 1, 1]);") - - result = await tool.execute({"path": str(other)}) - - assert not result.success - assert result.error == "wrong_session_dir" - - async def test_strict_fails_on_warnings(self, tool, tmp_path): - sess_dir = tmp_path / "sessions" / "sess-1" - sess_dir.mkdir(parents=True) - (sess_dir / "warn.scad").write_text("hole_diameter = 3.2; cylinder(d=hole_diameter/2, h=5);") - - result = await tool.execute({"path": "warn.scad", "strict": True}) - - assert not result.success - assert result.error == "scad_lint_failed"