New concrete base methods (backend-agnostic, use entity_get + pure math): - AutoCADBackend.point_intersection: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE with ref_x/ref_y disambiguation for multiple candidates. - AutoCADBackend.point_tangent: external-point-to-circle tangent touch-point with ref_x/ref_y for side selection; correct perpendicularity formula (T-F)⊥(T-C) via cos(θ-α)=-r/d. Registered as MCP tools: point_intersection, point_tangent. 13 new tests in tests/test_meta_tools_geometry.py. 226 tests total.
2923 lines
116 KiB
Python
2923 lines
116 KiB
Python
"""AutoCAD MCP Pro – FastMCP 3.0 server.
|
||
|
||
Dual engine: pywin32 COM API (live AutoCAD) + ezdxf (headless file ops).
|
||
The exact tool count is reported dynamically via system_status / system_about.
|
||
|
||
Usage:
|
||
python server.py # STDIO (default)
|
||
fastmcp run server.py:mcp --transport http --port 8000
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import sys
|
||
import time
|
||
from dataclasses import asdict
|
||
from typing import Annotated, Any
|
||
|
||
from fastmcp import Context, FastMCP
|
||
from fastmcp.exceptions import ToolError
|
||
from fastmcp.server.lifespan import lifespan
|
||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
||
from fastmcp.server.middleware.logging import LoggingMiddleware
|
||
from fastmcp.server.middleware.timing import TimingMiddleware
|
||
from pydantic import Field
|
||
|
||
import config
|
||
from security import sanitize_command, sanitize_lisp, validate_path
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Logging
|
||
# ---------------------------------------------------------------------------
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
)
|
||
log = logging.getLogger("autocad_mcp")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Backend auto-detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_WIN32 = sys.platform == "win32"
|
||
|
||
|
||
def _detect_autocad_running() -> bool:
|
||
if not _WIN32:
|
||
return False
|
||
try:
|
||
import win32gui
|
||
result = {"found": False}
|
||
def _cb(hwnd, _):
|
||
if "AutoCAD" in win32gui.GetWindowText(hwnd) and win32gui.IsWindowVisible(hwnd):
|
||
result["found"] = True
|
||
return False
|
||
return True
|
||
win32gui.EnumWindows(_cb, None)
|
||
return result["found"]
|
||
except Exception as exc:
|
||
log.debug("AutoCAD detection failed: %s", exc)
|
||
return False
|
||
|
||
|
||
async def _make_backend():
|
||
"""Create the best available backend."""
|
||
backend_env = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
|
||
|
||
if backend_env == "ezdxf":
|
||
from backends.ezdxf_backend import EzdxfBackend
|
||
b = EzdxfBackend()
|
||
await b.connect()
|
||
return b
|
||
|
||
if backend_env in ("auto", "com"):
|
||
if _WIN32:
|
||
try:
|
||
from backends.com_backend import ComBackend
|
||
b = ComBackend()
|
||
await b.connect()
|
||
log.info("Using COM backend (live AutoCAD control)")
|
||
return b
|
||
except Exception as exc:
|
||
log.warning("COM backend init failed (%s)", exc)
|
||
if backend_env == "com":
|
||
raise RuntimeError(
|
||
f"COM backend requested but failed: {exc}"
|
||
) from exc
|
||
else:
|
||
log.warning("COM backend requires Windows; falling back to ezdxf")
|
||
|
||
from backends.ezdxf_backend import EzdxfBackend
|
||
b = EzdxfBackend()
|
||
await b.connect()
|
||
log.info("Using ezdxf backend (headless mode)")
|
||
return b
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lifespan – backend singleton
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@lifespan
|
||
async def autocad_lifespan(server):
|
||
"""Initialize AutoCAD backend on server start, clean up on stop."""
|
||
log.info("Initializing AutoCAD MCP Pro...")
|
||
if config.settings.dangerous_commands_enabled:
|
||
log.warning(
|
||
"⚠ DANGEROUS_COMMANDS_ENABLED=true — command and LISP sanitization is "
|
||
"DISABLED. The server will execute any AutoCAD command or LISP expression "
|
||
"the client sends. Do NOT enable this on a network-reachable instance."
|
||
)
|
||
try:
|
||
backend = await _make_backend()
|
||
log.info("Backend ready: %s", backend.name)
|
||
yield {"backend": backend}
|
||
except Exception as exc:
|
||
log.error("Backend initialization failed: %s", exc)
|
||
yield {"backend": None, "init_error": str(exc)}
|
||
finally:
|
||
log.info("AutoCAD MCP Pro shutting down")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Middleware: audit log for destructive operations
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class AuditMiddleware(Middleware):
|
||
"""Log all tool calls with timing for audit trail."""
|
||
|
||
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
||
tool_name = context.message.name
|
||
start = time.monotonic()
|
||
try:
|
||
result = await call_next(context)
|
||
elapsed = (time.monotonic() - start) * 1000
|
||
log.info("TOOL OK %-40s %6.1fms", tool_name, elapsed)
|
||
return result
|
||
except Exception as exc:
|
||
elapsed = (time.monotonic() - start) * 1000
|
||
log.warning("TOOL ERR %-40s %6.1fms %s", tool_name, elapsed, exc)
|
||
raise
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FastMCP server
|
||
# ---------------------------------------------------------------------------
|
||
|
||
mcp = FastMCP(
|
||
name="AutoCAD MCP Pro",
|
||
instructions="""
|
||
AutoCAD MCP Pro provides complete AutoCAD automation through a large tool surface.
|
||
|
||
DUAL ENGINE:
|
||
- COM backend: Live AutoCAD control (requires AutoCAD running on Windows)
|
||
- ezdxf backend: Headless DXF file operations (no AutoCAD needed)
|
||
|
||
WORKFLOW:
|
||
1. drawing_open / drawing_new → open or create a drawing
|
||
2. layer_create → set up layers
|
||
3. entity_create_* → draw geometry
|
||
4. analysis_entity_stats → inspect drawing
|
||
5. view_screenshot → see current state
|
||
6. drawing_save → save your work
|
||
|
||
ENGINEERING WORKFLOW (parts requiring real production drawings):
|
||
1. drawing_new → auto-bootstraps engineering layers + linetypes
|
||
2. titleblock_apply_iso_a3 → standard ISO 7200 border + title (NEVER hand-draw the title)
|
||
3. gear_draw_helical_front_view / gear_draw_spur_front_view
|
||
→ parametric gear front view (NEVER hand-draw teeth or invent the title)
|
||
4. gear_draw_section_aa → parametric cross-section
|
||
5. dimension_* → real DIMENSION entities (NEVER use text+leader fakes)
|
||
6. drawing_finalize → 8-step validator + save + screenshot;
|
||
cannot complete without this; raises if invalid.
|
||
|
||
CRITICAL: For engineering drawings, NEVER manually draw gear teeth, keyways,
|
||
or section hatches with primitive line/circle calls. Use the gear_*, keyway_*,
|
||
and titleblock_* tools — they are deterministic and produce production-quality
|
||
output. The title text is whatever you pass to titleblock_apply_iso_a3 — do
|
||
not invent variants like "HELICAL SPUR GEAR".
|
||
|
||
HANDLES: Every entity has a unique handle (hex string). Use it for
|
||
entity_get, entity_move, entity_delete, entity_set_properties, etc.
|
||
|
||
TRANSACTIONS: Use transaction_begin / transaction_commit / transaction_rollback
|
||
to group operations with undo support.
|
||
|
||
Set AUTOCAD_MCP_BACKEND=com|ezdxf|auto environment variable to force a backend.
|
||
""",
|
||
lifespan=autocad_lifespan,
|
||
)
|
||
|
||
mcp.add_middleware(ErrorHandlingMiddleware())
|
||
mcp.add_middleware(AuditMiddleware())
|
||
mcp.add_middleware(TimingMiddleware())
|
||
mcp.add_middleware(LoggingMiddleware())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Backend access helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _backend(ctx: Context):
|
||
"""Get the backend from lifespan context, raising ToolError if not ready."""
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
err = ctx.lifespan_context.get("init_error", "Backend not initialized")
|
||
raise ToolError(f"AutoCAD backend unavailable: {err}")
|
||
return b
|
||
|
||
|
||
def _dc(obj) -> dict:
|
||
"""Convert dataclass to dict (recursively)."""
|
||
if hasattr(obj, "__dataclass_fields__"):
|
||
return asdict(obj)
|
||
if isinstance(obj, list):
|
||
return [_dc(i) for i in obj]
|
||
return obj
|
||
|
||
|
||
def _registered_tool_count() -> int:
|
||
"""Number of @mcp.tool registrations currently on the server.
|
||
|
||
Used by system_status / system_about so the count never drifts from reality.
|
||
"""
|
||
try:
|
||
components = mcp._local_provider._components
|
||
return sum(1 for k in components if k.startswith("tool:"))
|
||
except Exception:
|
||
return -1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 1: Drawing Management (11 tools) ────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing Info", "readOnlyHint": True},
|
||
tags={"drawing", "query"},
|
||
)
|
||
async def drawing_info(ctx: Context) -> dict:
|
||
"""Get comprehensive metadata for the current drawing.
|
||
|
||
Returns: name, path, entity_count, layer_count, block_count,
|
||
extents (min/max), units, version, backend name.
|
||
"""
|
||
await ctx.info("Fetching drawing info")
|
||
result = await _backend(ctx).drawing_info()
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "New Drawing", "destructiveHint": False},
|
||
tags={"drawing"},
|
||
)
|
||
async def drawing_new(
|
||
template: Annotated[str | None, "Optional path to .dwt template file"] = None,
|
||
bootstrap: Annotated[bool, Field(
|
||
description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard "
|
||
"engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, "
|
||
"HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.",
|
||
)] = True,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a new empty drawing, optionally from a template (.dwt).
|
||
|
||
When ``bootstrap=True`` (default), the drawing is also seeded with the
|
||
standard engineering linetypes (CENTER/HIDDEN/PHANTOM) and layers
|
||
(GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK).
|
||
"""
|
||
if template is not None:
|
||
validated_template = validate_path(template, allow_write=False)
|
||
template = str(validated_template)
|
||
await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
|
||
backend = _backend(ctx)
|
||
raw = await backend.drawing_new(template)
|
||
result = dict(raw) if isinstance(raw, dict) else {"result": raw}
|
||
if bootstrap:
|
||
try:
|
||
from engineering import (
|
||
ensure_engineering_layers,
|
||
ensure_standard_linetypes,
|
||
)
|
||
lt_status = await ensure_standard_linetypes(backend)
|
||
layer_status = await ensure_engineering_layers(backend)
|
||
result["bootstrap"] = {"ok": True, "linetypes": lt_status, "layers": layer_status}
|
||
except Exception as exc:
|
||
await ctx.warning(f"Engineering bootstrap failed: {exc}")
|
||
result["bootstrap"] = {"ok": False, "error": str(exc)}
|
||
result["status"] = "degraded"
|
||
return result
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Open Drawing"},
|
||
tags={"drawing"},
|
||
)
|
||
async def drawing_open(
|
||
path: Annotated[str, "Full path to the .dwg or .dxf file"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Open an existing DWG or DXF drawing file."""
|
||
validated = validate_path(path, allow_write=False)
|
||
await ctx.info(f"Opening drawing: {validated}")
|
||
await ctx.report_progress(0, 100)
|
||
result = await _backend(ctx).drawing_open(str(validated))
|
||
await ctx.report_progress(100, 100)
|
||
return result
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Save Drawing"},
|
||
tags={"drawing"},
|
||
)
|
||
async def drawing_save(
|
||
path: Annotated[str | None, "Optional save path; uses current path if omitted"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Save the current drawing. Optionally specify a new path."""
|
||
if path is not None:
|
||
validated = validate_path(path, allow_write=True)
|
||
path = str(validated)
|
||
await ctx.info("Saving drawing")
|
||
return await _backend(ctx).drawing_save(path)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Save As"},
|
||
tags={"drawing"},
|
||
)
|
||
async def drawing_save_as(
|
||
path: Annotated[str, "Full destination path including extension"],
|
||
format: Annotated[str, "Output format: dwg, dxf, dwt"] = "dwg",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Save current drawing to a new path/format (DWG, DXF, or DWT template)."""
|
||
validated = validate_path(path, allow_write=True)
|
||
await ctx.info(f"Saving as {format}: {validated}")
|
||
return await _backend(ctx).drawing_save_as(str(validated), format)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Export DXF"},
|
||
tags={"drawing", "export"},
|
||
)
|
||
async def drawing_export_dxf(
|
||
path: Annotated[str, "Output .dxf file path"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Export the current drawing as a DXF file."""
|
||
validated = validate_path(path, allow_write=True)
|
||
await ctx.info(f"Exporting DXF: {validated}")
|
||
return await _backend(ctx).drawing_export_dxf(str(validated))
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Export PDF"},
|
||
tags={"drawing", "export"},
|
||
)
|
||
async def drawing_export_pdf(
|
||
path: Annotated[str, "Output .pdf file path"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Export the current drawing to PDF."""
|
||
validated = validate_path(path, allow_write=True)
|
||
await ctx.info(f"Exporting PDF: {validated}")
|
||
await ctx.report_progress(0, 100)
|
||
result = await _backend(ctx).drawing_export_pdf(str(validated))
|
||
await ctx.report_progress(100, 100)
|
||
return result
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Purge Drawing"},
|
||
tags={"drawing", "cleanup"},
|
||
)
|
||
async def drawing_purge(ctx: Context = None) -> dict:
|
||
"""Purge all unused objects (layers, blocks, linetypes, styles) from the drawing."""
|
||
await ctx.info("Purging drawing")
|
||
return await _backend(ctx).drawing_purge()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Audit Drawing", "readOnlyHint": False},
|
||
tags={"drawing", "cleanup"},
|
||
)
|
||
async def drawing_audit(ctx: Context = None) -> dict:
|
||
"""Run an integrity audit on the drawing to detect and fix errors."""
|
||
await ctx.info("Auditing drawing")
|
||
return await _backend(ctx).drawing_audit()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Close Drawing", "destructiveHint": True},
|
||
tags={"drawing"},
|
||
)
|
||
async def drawing_close(
|
||
save: Annotated[bool, "Save the drawing before closing"] = True,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Close the current drawing. If save is True (default), the drawing is
|
||
saved to its current path before closing. After this call, you must call
|
||
drawing_new or drawing_open before any other tool."""
|
||
await ctx.info(f"Closing drawing (save={save})")
|
||
return await _backend(ctx).drawing_close(save)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Undo", "destructiveHint": False, "idempotentHint": False},
|
||
tags={"drawing", "undo"},
|
||
)
|
||
async def drawing_undo(ctx: Context = None) -> dict:
|
||
"""Undo the last drawing operation."""
|
||
return await _backend(ctx).drawing_undo()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Redo", "destructiveHint": False},
|
||
tags={"drawing", "undo"},
|
||
)
|
||
async def drawing_redo(ctx: Context = None) -> dict:
|
||
"""Redo the last undone drawing operation."""
|
||
return await _backend(ctx).drawing_redo()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 2: Entity Creation (13 tools) ───────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Line", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_line(
|
||
x1: Annotated[float, "Start X coordinate"],
|
||
y1: Annotated[float, "Start Y coordinate"],
|
||
x2: Annotated[float, "End X coordinate"],
|
||
y2: Annotated[float, "End Y coordinate"],
|
||
z1: Annotated[float, "Start Z coordinate (default 0)"] = 0.0,
|
||
z2: Annotated[float, "End Z coordinate (default 0)"] = 0.0,
|
||
layer: Annotated[str | None, "Layer name (default: current layer)"] = None,
|
||
color: Annotated[int | None, "ACI color code 1-255, 256=ByLayer, 0=ByBlock"] = None,
|
||
linetype: Annotated[str | None, "Linetype name (e.g. 'DASHED', 'CENTER')"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a line from (x1,y1) to (x2,y2). Returns entity info with handle."""
|
||
await ctx.debug(f"Creating line ({x1},{y1}) → ({x2},{y2})")
|
||
result = await _backend(ctx).entity_create_line(x1, y1, x2, y2, z1, z2, layer, color, linetype)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Circle", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_circle(
|
||
cx: Annotated[float, "Center X"],
|
||
cy: Annotated[float, "Center Y"],
|
||
radius: Annotated[float, Field(description="Circle radius", gt=0)],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a circle at (cx, cy) with given radius."""
|
||
await ctx.debug(f"Creating circle center=({cx},{cy}) r={radius}")
|
||
result = await _backend(ctx).entity_create_circle(cx, cy, radius, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Arc", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_arc(
|
||
cx: Annotated[float, "Center X"],
|
||
cy: Annotated[float, "Center Y"],
|
||
radius: Annotated[float, Field(description="Arc radius", gt=0)],
|
||
start_angle: Annotated[float, "Start angle in degrees (0 = right, CCW positive)"],
|
||
end_angle: Annotated[float, "End angle in degrees"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a circular arc. Angles are in degrees, measured counter-clockwise from the positive X axis."""
|
||
await ctx.debug(f"Creating arc center=({cx},{cy}) r={radius} {start_angle}°→{end_angle}°")
|
||
result = await _backend(ctx).entity_create_arc(cx, cy, radius, start_angle, end_angle, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Polyline", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_polyline(
|
||
points: Annotated[list[list[float]], "List of [x, y] coordinate pairs"],
|
||
closed: Annotated[bool, "Whether to close the polyline"] = False,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a lightweight 2D polyline through the given points.
|
||
|
||
Example: points=[[0,0],[100,0],[100,100],[0,100]], closed=true → rectangle
|
||
"""
|
||
await ctx.debug(f"Creating polyline with {len(points)} points, closed={closed}")
|
||
result = await _backend(ctx).entity_create_polyline(points, closed, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Rectangle", "readOnlyHint": False, "idempotentHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_rectangle(
|
||
x1: Annotated[float, "First corner X"],
|
||
y1: Annotated[float, "First corner Y"],
|
||
x2: Annotated[float, "Opposite corner X"],
|
||
y2: Annotated[float, "Opposite corner Y"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a closed rectangular polyline between two corner points.
|
||
|
||
Convenience wrapper around entity_create_polyline.
|
||
"""
|
||
await ctx.debug(f"Creating rectangle ({x1},{y1}) - ({x2},{y2})")
|
||
pts = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
|
||
result = await _backend(ctx).entity_create_polyline(pts, closed=True, layer=layer, color=color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Text", "readOnlyHint": False},
|
||
tags={"entity", "create", "annotation"},
|
||
)
|
||
async def entity_create_text(
|
||
text: Annotated[str, "Text content to display"],
|
||
x: Annotated[float, "Insertion point X"],
|
||
y: Annotated[float, "Insertion point Y"],
|
||
height: Annotated[float, Field(description="Text height in drawing units", gt=0)] = 2.5,
|
||
rotation: Annotated[float, "Rotation angle in degrees"] = 0.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a single-line text entity (DTEXT/TEXT)."""
|
||
await ctx.debug(f"Creating text: '{text[:30]}' at ({x},{y})")
|
||
result = await _backend(ctx).entity_create_text(text, x, y, height, rotation, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create MText", "readOnlyHint": False},
|
||
tags={"entity", "create", "annotation"},
|
||
)
|
||
async def entity_create_mtext(
|
||
text: Annotated[str, "Text content (supports \\P for paragraph breaks, {\\H...;} for formatting)"],
|
||
x: Annotated[float, "Insertion point X"],
|
||
y: Annotated[float, "Insertion point Y"],
|
||
width: Annotated[float, "Text box width in drawing units"] = 100.0,
|
||
height: Annotated[float, "Character height in drawing units"] = 2.5,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a multi-line text entity (MTEXT) with word-wrap at the specified width."""
|
||
await ctx.debug(f"Creating mtext at ({x},{y}) w={width}")
|
||
result = await _backend(ctx).entity_create_mtext(text, x, y, width, height, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Hatch", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_hatch(
|
||
pattern: Annotated[str, "Hatch pattern name: SOLID, ANSI31, ANSI32, STEEL, GRAVEL, etc."],
|
||
boundary_points: Annotated[list[list[float]], "Closed boundary as list of [x, y] points"],
|
||
scale: Annotated[float, Field(description="Pattern scale factor", gt=0)] = 1.0,
|
||
angle: Annotated[float, "Pattern rotation angle in degrees"] = 0.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a hatch fill pattern inside a closed boundary polygon."""
|
||
await ctx.debug(f"Creating hatch pattern={pattern} scale={scale}")
|
||
result = await _backend(ctx).entity_create_hatch(pattern, boundary_points, scale, angle, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Spline", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_spline(
|
||
fit_points: Annotated[list[list[float]], "List of [x, y] fit points the spline passes through"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a NURBS spline curve passing through the specified fit points."""
|
||
await ctx.debug(f"Creating spline with {len(fit_points)} fit points")
|
||
result = await _backend(ctx).entity_create_spline(fit_points, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Ellipse", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_ellipse(
|
||
cx: Annotated[float, "Center X"],
|
||
cy: Annotated[float, "Center Y"],
|
||
major_x: Annotated[float, "Major axis endpoint X (relative to center)"],
|
||
major_y: Annotated[float, "Major axis endpoint Y (relative to center)"],
|
||
ratio: Annotated[float, Field(description="Minor-to-major axis ratio (0 < ratio ≤ 1)", gt=0, le=1)] = 0.5,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create an ellipse. major_x/major_y define the major axis vector from the center."""
|
||
await ctx.debug(f"Creating ellipse center=({cx},{cy}) major=({major_x},{major_y}) ratio={ratio}")
|
||
result = await _backend(ctx).entity_create_ellipse(cx, cy, major_x, major_y, ratio, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Point", "readOnlyHint": False},
|
||
tags={"entity", "create"},
|
||
)
|
||
async def entity_create_point(
|
||
x: Annotated[float, "Point X coordinate"],
|
||
y: Annotated[float, "Point Y coordinate"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
color: Annotated[int | None, "ACI color code"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a point marker entity at (x, y)."""
|
||
result = await _backend(ctx).entity_create_point(x, y, layer, color)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Insert Block Reference", "readOnlyHint": False},
|
||
tags={"entity", "create", "block"},
|
||
)
|
||
async def entity_create_block_ref(
|
||
name: Annotated[str, "Block definition name (must exist in drawing)"],
|
||
x: Annotated[float, "Insertion X"],
|
||
y: Annotated[float, "Insertion Y"],
|
||
scale_x: Annotated[float, "X scale factor"] = 1.0,
|
||
scale_y: Annotated[float, "Y scale factor"] = 1.0,
|
||
rotation: Annotated[float, "Rotation angle in degrees"] = 0.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Insert a block reference (instance of an existing block definition)."""
|
||
await ctx.debug(f"Inserting block '{name}' at ({x},{y})")
|
||
result = await _backend(ctx).entity_create_block_ref(name, x, y, scale_x, scale_y, rotation, layer)
|
||
return _dc(result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 3: Dimensions (5 tools) ─────────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Linear Dimension", "readOnlyHint": False},
|
||
tags={"annotation", "dimension"},
|
||
)
|
||
async def dimension_linear(
|
||
x1: Annotated[float, "First extension line origin X"],
|
||
y1: Annotated[float, "First extension line origin Y"],
|
||
x2: Annotated[float, "Second extension line origin X"],
|
||
y2: Annotated[float, "Second extension line origin Y"],
|
||
dim_x: Annotated[float, "Dimension line position X"],
|
||
dim_y: Annotated[float, "Dimension line position Y"],
|
||
rotation: Annotated[float, "Angle of the measured dimension (0=horizontal, 90=vertical)"] = 0.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a linear dimension measuring horizontal or vertical distance."""
|
||
result = await _backend(ctx).dimension_linear(x1, y1, x2, y2, dim_x, dim_y, rotation, layer)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Aligned Dimension", "readOnlyHint": False},
|
||
tags={"annotation", "dimension"},
|
||
)
|
||
async def dimension_aligned(
|
||
x1: Annotated[float, "First point X"],
|
||
y1: Annotated[float, "First point Y"],
|
||
x2: Annotated[float, "Second point X"],
|
||
y2: Annotated[float, "Second point Y"],
|
||
dim_x: Annotated[float, "Dimension line position X"],
|
||
dim_y: Annotated[float, "Dimension line position Y"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create an aligned dimension that measures the true distance between two points."""
|
||
result = await _backend(ctx).dimension_aligned(x1, y1, x2, y2, dim_x, dim_y, layer)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Angular Dimension", "readOnlyHint": False},
|
||
tags={"annotation", "dimension"},
|
||
)
|
||
async def dimension_angular(
|
||
vertex_x: Annotated[float, "Angle vertex X"],
|
||
vertex_y: Annotated[float, "Angle vertex Y"],
|
||
x1: Annotated[float, "First ray endpoint X"],
|
||
y1: Annotated[float, "First ray endpoint Y"],
|
||
x2: Annotated[float, "Second ray endpoint X"],
|
||
y2: Annotated[float, "Second ray endpoint Y"],
|
||
text_x: Annotated[float, "Dimension text position X"],
|
||
text_y: Annotated[float, "Dimension text position Y"],
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create an angular dimension measuring the angle between two lines from a vertex."""
|
||
result = await _backend(ctx).dimension_angular(
|
||
vertex_x, vertex_y, x1, y1, x2, y2, text_x, text_y, layer
|
||
)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Radius Dimension", "readOnlyHint": False},
|
||
tags={"annotation", "dimension"},
|
||
)
|
||
async def dimension_radius(
|
||
center_x: Annotated[float, "Circle/arc center X"],
|
||
center_y: Annotated[float, "Circle/arc center Y"],
|
||
chord_x: Annotated[float, "Point on the circle/arc X (determines angle)"],
|
||
chord_y: Annotated[float, "Point on the circle/arc Y"],
|
||
leader_length: Annotated[float, "Length of the leader line"] = 10.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a radius dimension for a circle or arc."""
|
||
result = await _backend(ctx).dimension_radius(center_x, center_y, chord_x, chord_y, leader_length, layer)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Diameter Dimension", "readOnlyHint": False},
|
||
tags={"annotation", "dimension"},
|
||
)
|
||
async def dimension_diameter(
|
||
x1: Annotated[float, "First point on diameter X"],
|
||
y1: Annotated[float, "First point on diameter Y"],
|
||
x2: Annotated[float, "Second point on diameter (opposite side) X"],
|
||
y2: Annotated[float, "Second point on diameter Y"],
|
||
leader_length: Annotated[float, "Leader line length"] = 10.0,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a diameter dimension for a circle (two points on opposite sides)."""
|
||
result = await _backend(ctx).dimension_diameter(x1, y1, x2, y2, leader_length, layer)
|
||
return _dc(result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 4: Entity Modification (10 tools) ───────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Move Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_move(
|
||
handle: Annotated[str, "Entity handle (hex string from entity_list or entity_create_*)"],
|
||
dx: Annotated[float, "X displacement"],
|
||
dy: Annotated[float, "Y displacement"],
|
||
dz: Annotated[float, "Z displacement"] = 0.0,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Move an entity by the specified displacement vector (dx, dy, dz)."""
|
||
await ctx.debug(f"Moving entity {handle} by ({dx},{dy},{dz})")
|
||
return await _backend(ctx).entity_move(handle, dx, dy, dz)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Copy Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_copy(
|
||
handle: Annotated[str, "Entity handle to copy"],
|
||
dx: Annotated[float, "X displacement for the copy"],
|
||
dy: Annotated[float, "Y displacement for the copy"],
|
||
dz: Annotated[float, "Z displacement"] = 0.0,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Copy an entity and move the copy by (dx, dy, dz). Returns info of the new copy."""
|
||
await ctx.debug(f"Copying entity {handle}")
|
||
result = await _backend(ctx).entity_copy(handle, dx, dy, dz)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Rotate Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_rotate(
|
||
handle: Annotated[str, "Entity handle"],
|
||
base_x: Annotated[float, "Rotation base point X"],
|
||
base_y: Annotated[float, "Rotation base point Y"],
|
||
angle_deg: Annotated[float, "Rotation angle in degrees (positive = counter-clockwise)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Rotate an entity around a base point by the specified angle."""
|
||
await ctx.debug(f"Rotating entity {handle} by {angle_deg}° around ({base_x},{base_y})")
|
||
return await _backend(ctx).entity_rotate(handle, base_x, base_y, angle_deg)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Scale Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_scale(
|
||
handle: Annotated[str, "Entity handle"],
|
||
base_x: Annotated[float, "Scale base point X"],
|
||
base_y: Annotated[float, "Scale base point Y"],
|
||
factor: Annotated[float, Field(description="Scale factor (>1 enlarges, <1 shrinks)", gt=0)],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Scale an entity uniformly from a base point."""
|
||
return await _backend(ctx).entity_scale(handle, base_x, base_y, factor)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Mirror Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_mirror(
|
||
handle: Annotated[str, "Entity handle"],
|
||
x1: Annotated[float, "Mirror line first point X"],
|
||
y1: Annotated[float, "Mirror line first point Y"],
|
||
x2: Annotated[float, "Mirror line second point X"],
|
||
y2: Annotated[float, "Mirror line second point Y"],
|
||
delete_original: Annotated[bool, "Delete original after mirroring"] = False,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Mirror an entity across a line defined by two points. Returns the mirrored copy."""
|
||
result = await _backend(ctx).entity_mirror(handle, x1, y1, x2, y2, delete_original)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Offset Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_offset(
|
||
handle: Annotated[str, "Entity handle (line, circle, or polyline)"],
|
||
distance: Annotated[float, "Offset distance (positive = outward/right)"],
|
||
side_x: Annotated[float | None, "X coordinate of a point on the offset side (optional)"] = None,
|
||
side_y: Annotated[float | None, "Y coordinate of a point on the offset side (optional)"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a parallel copy of a line, circle, or polyline at the given distance."""
|
||
result = await _backend(ctx).entity_offset(handle, distance, side_x, side_y)
|
||
return _dc(result)
|
||
|
||
|
||
# ── corner operations (trim/extend/fillet/chamfer) ──────────────────────────
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Trim Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify", "corner"},
|
||
)
|
||
async def entity_trim(
|
||
target_handle: Annotated[str, "Handle of the line being trimmed"],
|
||
cutter_handle: Annotated[str, "Handle of the cutting line"],
|
||
keep_x: Annotated[float, "X of a point on the side of the target to KEEP"],
|
||
keep_y: Annotated[float, "Y of a point on the side of the target to KEEP"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Trim `target` against `cutter`, keeping the segment containing (keep_x, keep_y).
|
||
|
||
V1 supports LINE+LINE only. Cutter is treated as an infinite ray (AutoCAD's
|
||
default 'implied extend' trim mode). Raises if the lines are parallel.
|
||
"""
|
||
result = await _backend(ctx).entity_trim(target_handle, cutter_handle, keep_x, keep_y)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Extend Entity", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify", "corner"},
|
||
)
|
||
async def entity_extend(
|
||
target_handle: Annotated[str, "Handle of the line being extended"],
|
||
boundary_handle: Annotated[str, "Handle of the boundary line"],
|
||
end_x: Annotated[float | None, "X of a point near the endpoint to extend (None = auto)"] = None,
|
||
end_y: Annotated[float | None, "Y of a point near the endpoint to extend (None = auto)"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Extend `target` to meet `boundary`. If end_x/end_y is None, the target
|
||
endpoint nearest the boundary is auto-selected.
|
||
|
||
V1 supports LINE+LINE only. Raises if the lines are parallel.
|
||
"""
|
||
result = await _backend(ctx).entity_extend(target_handle, boundary_handle, end_x, end_y)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Fillet Two Entities", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify", "corner"},
|
||
)
|
||
async def entity_fillet(
|
||
handle1: Annotated[str, "First entity handle"],
|
||
handle2: Annotated[str, "Second entity handle"],
|
||
radius: Annotated[float, Field(description="Fillet radius (>= 0; 0 = sharp corner / corner-merge)", ge=0.0)],
|
||
trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Round a corner with a tangent arc. Returns info on the new ARC entity
|
||
(or the first source line for radius=0). V1 supports LINE+LINE only."""
|
||
result = await _backend(ctx).entity_fillet(handle1, handle2, radius, trim)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Chamfer Two Entities", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify", "corner"},
|
||
)
|
||
async def entity_chamfer(
|
||
handle1: Annotated[str, "First entity handle"],
|
||
handle2: Annotated[str, "Second entity handle"],
|
||
dist1: Annotated[float, Field(description="Chamfer distance along first line", gt=0.0)],
|
||
dist2: Annotated[float | None, "Chamfer distance along second line (None = symmetric, dist2=dist1)"] = None,
|
||
trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Bevel a corner with a chamfer line. Returns info on the new chamfer LINE.
|
||
V1 supports LINE+LINE only."""
|
||
result = await _backend(ctx).entity_chamfer(handle1, handle2, dist1, dist2, trim)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Delete Entity", "readOnlyHint": False, "destructiveHint": True},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_delete(
|
||
handle: Annotated[str, "Entity handle to delete"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Permanently delete an entity by its handle."""
|
||
await ctx.warning(f"Deleting entity {handle}")
|
||
return await _backend(ctx).entity_delete(handle)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Rectangular Array", "readOnlyHint": False},
|
||
tags={"entity", "modify", "array"},
|
||
)
|
||
async def entity_array_rectangular(
|
||
handle: Annotated[str, "Entity handle to array"],
|
||
rows: Annotated[int, Field(description="Number of rows", ge=1)],
|
||
cols: Annotated[int, Field(description="Number of columns", ge=1)],
|
||
row_spacing: Annotated[float, "Spacing between rows (Y direction)"],
|
||
col_spacing: Annotated[float, "Spacing between columns (X direction)"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Create a rectangular array of copies. Returns info of all created copies."""
|
||
await ctx.info(f"Creating {rows}×{cols} rectangular array of entity {handle}")
|
||
total = rows * cols
|
||
await ctx.report_progress(0, total)
|
||
result = await _backend(ctx).entity_array_rectangular(handle, rows, cols, row_spacing, col_spacing)
|
||
await ctx.report_progress(total, total)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Polar Array", "readOnlyHint": False},
|
||
tags={"entity", "modify", "array"},
|
||
)
|
||
async def entity_array_polar(
|
||
handle: Annotated[str, "Entity handle to array"],
|
||
count: Annotated[int, Field(description="Total number of items in the array", ge=2)],
|
||
fill_angle: Annotated[float, "Total angle to fill in degrees (360 for full circle)"],
|
||
center_x: Annotated[float, "Array center X"],
|
||
center_y: Annotated[float, "Array center Y"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Create a polar (circular) array of copies around a center point."""
|
||
await ctx.info(f"Creating polar array of {count} items around ({center_x},{center_y})")
|
||
result = await _backend(ctx).entity_array_polar(handle, count, fill_angle, center_x, center_y)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Set Entity Properties", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_set_properties(
|
||
handle: Annotated[str, "Entity handle"],
|
||
layer: Annotated[str | None, "New layer name"] = None,
|
||
color: Annotated[int | None, "New ACI color (256=ByLayer, 0=ByBlock, 1-255=specific)"] = None,
|
||
linetype: Annotated[str | None, "New linetype name (e.g. 'DASHED', 'CENTER', 'ByLayer')"] = None,
|
||
lineweight: Annotated[int | None, "Lineweight in 0.01mm units (-3=ByLayer, -2=ByBlock)"] = None,
|
||
visible: Annotated[bool | None, "Set entity visibility"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Change one or more properties of an entity (layer, color, linetype, lineweight, visibility)."""
|
||
await ctx.debug(f"Setting properties for entity {handle}")
|
||
return await _backend(ctx).entity_set_properties(handle, layer, color, linetype, lineweight, visible)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 5: Entity Query (3 tools) ───────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Get Entity", "readOnlyHint": True},
|
||
tags={"entity", "query"},
|
||
)
|
||
async def entity_get(
|
||
handle: Annotated[str, "Entity handle"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Get all properties of a specific entity by its handle."""
|
||
result = await _backend(ctx).entity_get(handle)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "List Entities", "readOnlyHint": True},
|
||
tags={"entity", "query"},
|
||
)
|
||
async def entity_list(
|
||
type_filter: Annotated[str | None, "Filter by entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, etc."] = None,
|
||
layer_filter: Annotated[str | None, "Filter by layer name"] = None,
|
||
limit: Annotated[int, Field(description="Maximum entities to return", ge=1, le=1000)] = 100,
|
||
offset: Annotated[int, Field(description="Number of entities to skip", ge=0)] = 0,
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""List entities in the drawing with optional type and layer filters.
|
||
|
||
Returns handle, type, layer, color, and type-specific properties.
|
||
Use handles with entity_get, entity_move, entity_delete, etc.
|
||
"""
|
||
capped = min(int(limit), config.settings.max_list_limit)
|
||
if capped < limit:
|
||
await ctx.warning(
|
||
f"limit {limit} exceeds MAX_LIST_LIMIT={config.settings.max_list_limit}; capped"
|
||
)
|
||
await ctx.info(f"Listing entities type={type_filter} layer={layer_filter} limit={capped}")
|
||
result = await _backend(ctx).entity_list(type_filter, layer_filter, capped, offset)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Delete Multiple Entities", "readOnlyHint": False, "destructiveHint": True},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_delete_many(
|
||
handles: Annotated[list[str], "List of entity handles to delete"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Delete multiple entities in one call. Returns count of deleted entities."""
|
||
await ctx.info(f"Deleting {len(handles)} entities")
|
||
b = _backend(ctx)
|
||
deleted = 0
|
||
errors = []
|
||
for i, h in enumerate(handles):
|
||
await ctx.report_progress(i, len(handles))
|
||
try:
|
||
await b.entity_delete(h)
|
||
deleted += 1
|
||
except Exception as exc:
|
||
errors.append({"handle": h, "error": str(exc)})
|
||
await ctx.report_progress(len(handles), len(handles))
|
||
return {"ok": True, "deleted": deleted, "errors": errors}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 6: Layer Management (12 tools) ──────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "List Layers", "readOnlyHint": True},
|
||
tags={"layer", "query"},
|
||
)
|
||
async def layer_list(ctx: Context = None) -> list[dict]:
|
||
"""List all layers with their properties (color, linetype, frozen, locked, visibility)."""
|
||
result = await _backend(ctx).layer_list()
|
||
return [_dc(lyr) for lyr in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Layer", "readOnlyHint": False},
|
||
tags={"layer"},
|
||
)
|
||
async def layer_create(
|
||
name: Annotated[str, "New layer name"],
|
||
color: Annotated[int, "ACI color code (1=Red, 2=Yellow, 3=Green, 4=Cyan, 5=Blue, 7=White)"] = 7,
|
||
linetype: Annotated[str, "Linetype name"] = "Continuous",
|
||
lineweight: Annotated[int, "Lineweight (-3=ByLayer, 0=0.00mm, 13=0.13mm, 25=0.25mm, 50=0.50mm)"] = -3,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a new layer with specified properties."""
|
||
await ctx.info(f"Creating layer '{name}' color={color}")
|
||
result = await _backend(ctx).layer_create(name, color, linetype, lineweight)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Delete Layer", "readOnlyHint": False, "destructiveHint": True},
|
||
tags={"layer"},
|
||
)
|
||
async def layer_delete(
|
||
name: Annotated[str, "Layer name to delete (layer must be empty)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Delete a layer. The layer must have no entities. Layer '0' cannot be deleted."""
|
||
await ctx.warning(f"Deleting layer '{name}'")
|
||
return await _backend(ctx).layer_delete(name)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Set Current Layer", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"layer"},
|
||
)
|
||
async def layer_set_current(
|
||
name: Annotated[str, "Layer name to set as current"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Set the active/current layer for new entities."""
|
||
await ctx.info(f"Setting current layer to '{name}'")
|
||
return await _backend(ctx).layer_set_current(name)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Modify Layer", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"layer"},
|
||
)
|
||
async def layer_modify(
|
||
name: Annotated[str, "Layer name to modify"],
|
||
color: Annotated[int | None, "New ACI color code"] = None,
|
||
linetype: Annotated[str | None, "New linetype name"] = None,
|
||
lineweight: Annotated[int | None, "New lineweight value"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Modify an existing layer's color, linetype, and/or lineweight."""
|
||
result = await _backend(ctx).layer_modify(name, color, linetype, lineweight)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Freeze Layer"}, tags={"layer"})
|
||
async def layer_freeze(
|
||
name: Annotated[str, "Layer name to freeze"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Freeze a layer (makes it invisible and unselectable, faster regeneration)."""
|
||
return await _backend(ctx).layer_freeze(name)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Thaw Layer"}, tags={"layer"})
|
||
async def layer_thaw(
|
||
name: Annotated[str, "Layer name to thaw"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Thaw a frozen layer, making it visible and selectable again."""
|
||
return await _backend(ctx).layer_thaw(name)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Lock Layer"}, tags={"layer"})
|
||
async def layer_lock(
|
||
name: Annotated[str, "Layer name to lock"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Lock a layer (entities visible but cannot be selected or modified)."""
|
||
return await _backend(ctx).layer_lock(name)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Unlock Layer"}, tags={"layer"})
|
||
async def layer_unlock(
|
||
name: Annotated[str, "Layer name to unlock"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Unlock a layer to allow entity selection and modification."""
|
||
return await _backend(ctx).layer_unlock(name)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Hide Layer"}, tags={"layer"})
|
||
async def layer_hide(
|
||
name: Annotated[str, "Layer name to turn off"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Turn off a layer (entities invisible but still processed in regeneration)."""
|
||
return await _backend(ctx).layer_hide(name)
|
||
|
||
|
||
@mcp.tool(annotations={"title": "Show Layer"}, tags={"layer"})
|
||
async def layer_show(
|
||
name: Annotated[str, "Layer name to turn on"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Turn on a layer that was previously turned off."""
|
||
return await _backend(ctx).layer_show(name)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Isolate Layer", "readOnlyHint": False},
|
||
tags={"layer"},
|
||
)
|
||
async def layer_isolate(
|
||
name: Annotated[str, "Layer name to keep visible (all others will be hidden)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Hide all layers except the specified one (layer isolation)."""
|
||
await ctx.info(f"Isolating layer '{name}'")
|
||
b = _backend(ctx)
|
||
layers = await b.layer_list()
|
||
hidden = []
|
||
for lyr in layers:
|
||
if lyr.name != name and lyr.name != "0":
|
||
await b.layer_hide(lyr.name)
|
||
hidden.append(lyr.name)
|
||
return {"ok": True, "isolated": name, "hidden_count": len(hidden), "hidden_layers": hidden}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "List Loaded Linetypes", "readOnlyHint": True},
|
||
tags={"layer", "linetype"},
|
||
)
|
||
async def linetype_list(ctx: Context = None) -> list[str]:
|
||
"""Return the names of all linetypes currently loaded in the active drawing."""
|
||
return await _backend(ctx).linetype_list()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Load Linetype", "readOnlyHint": False},
|
||
tags={"layer", "linetype"},
|
||
)
|
||
async def linetype_load(
|
||
name: Annotated[str, "Linetype name to load (e.g. 'CENTER', 'DASHED', 'HIDDEN')"],
|
||
file: Annotated[
|
||
str | None,
|
||
"Optional .lin file. Defaults to acadiso.lin (metric) or acad.lin "
|
||
"(imperial), chosen from MEASUREMENT. Ignored by ezdxf backend."
|
||
] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Load a single linetype safely.
|
||
|
||
Use this instead of `system_run_command('_-LINETYPE _LOAD ...')` — that
|
||
raw form can deadlock on the FILEDIA file-picker dialog and on the
|
||
-LINETYPE option-menu prompt. This tool sets FILEDIA=0 around the call,
|
||
picks the right .lin file from MEASUREMENT, and verifies the linetype
|
||
actually loaded.
|
||
"""
|
||
return await _backend(ctx).linetype_load(name, file)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 7: Block Operations (7 tools) ───────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "List Blocks", "readOnlyHint": True},
|
||
tags={"block", "query"},
|
||
)
|
||
async def block_list(ctx: Context = None) -> list[dict]:
|
||
"""List all block definitions in the drawing (name, origin, attribute count, entity count)."""
|
||
result = await _backend(ctx).block_list()
|
||
return [_dc(b) for b in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Insert Block", "readOnlyHint": False},
|
||
tags={"block"},
|
||
)
|
||
async def block_insert(
|
||
name: Annotated[str, "Block definition name"],
|
||
x: Annotated[float, "Insertion X"],
|
||
y: Annotated[float, "Insertion Y"],
|
||
scale_x: Annotated[float, "X scale factor"] = 1.0,
|
||
scale_y: Annotated[float, "Y scale factor"] = 1.0,
|
||
rotation: Annotated[float, "Rotation angle in degrees"] = 0.0,
|
||
attributes: Annotated[dict | None, "Attribute values: {TAG: value}"] = None,
|
||
layer: Annotated[str | None, "Layer name"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Insert a block and optionally set attribute values."""
|
||
await ctx.info(f"Inserting block '{name}' at ({x},{y})")
|
||
result = await _backend(ctx).block_insert(name, x, y, scale_x, scale_y, rotation, attributes, layer)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Explode Block", "readOnlyHint": False, "destructiveHint": True},
|
||
tags={"block"},
|
||
)
|
||
async def block_explode(
|
||
handle: Annotated[str, "Block reference (INSERT) entity handle"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Explode a block reference into its individual component entities."""
|
||
await ctx.warning(f"Exploding block reference {handle}")
|
||
return await _backend(ctx).block_explode(handle)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Get Block Attributes", "readOnlyHint": True},
|
||
tags={"block", "query"},
|
||
)
|
||
async def block_get_attributes(
|
||
handle: Annotated[str, "Block reference (INSERT) entity handle"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Get all attribute values from a block reference as {TAG: value} dict."""
|
||
return await _backend(ctx).block_get_attributes(handle)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Set Block Attributes", "readOnlyHint": False},
|
||
tags={"block"},
|
||
)
|
||
async def block_set_attributes(
|
||
handle: Annotated[str, "Block reference (INSERT) entity handle"],
|
||
attributes: Annotated[dict, "Attribute values to update: {TAG: new_value}"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Update attribute values in a block reference."""
|
||
return await _backend(ctx).block_set_attributes(handle, attributes)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Create Block From Entities", "readOnlyHint": False},
|
||
tags={"block"},
|
||
)
|
||
async def block_create_from_entities(
|
||
name: Annotated[str, "New block definition name"],
|
||
handles: Annotated[list[str], "List of entity handles to include in the block"],
|
||
base_x: Annotated[float, "Block base point X"] = 0.0,
|
||
base_y: Annotated[float, "Block base point Y"] = 0.0,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a new block definition from existing entities in the drawing.
|
||
|
||
Note: This tool works by using AutoCAD's BLOCK command (COM backend only).
|
||
For ezdxf backend, entities must be added to a block definition directly.
|
||
"""
|
||
await ctx.info(f"Creating block '{name}' from {len(handles)} entities")
|
||
return await _backend(ctx).block_create_from_entities(name, handles, base_x, base_y)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Find Blocks By Name", "readOnlyHint": True},
|
||
tags={"block", "query"},
|
||
)
|
||
async def block_find_references(
|
||
name: Annotated[str, "Block definition name to search for"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Find all insert references to a specific block definition."""
|
||
await ctx.info(f"Finding all references to block '{name}'")
|
||
result = await _backend(ctx).entity_list(type_filter="INSERT")
|
||
refs = [_dc(e) for e in result if e.properties.get("block_name") == name]
|
||
return refs
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 8: Analysis & Query (8 tools) ───────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Entity Statistics", "readOnlyHint": True},
|
||
tags={"analysis", "query"},
|
||
)
|
||
async def analysis_entity_stats(ctx: Context = None) -> dict:
|
||
"""Analyze the drawing and return entity counts grouped by type and by layer.
|
||
|
||
Returns: total_entities, by_type (sorted by count), by_layer (sorted by count).
|
||
This is unique to AutoCAD MCP Pro – no other MCP server provides this!
|
||
"""
|
||
await ctx.info("Analyzing drawing statistics")
|
||
return await _backend(ctx).analysis_stats()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Find Entities in Region", "readOnlyHint": True},
|
||
tags={"analysis", "query"},
|
||
)
|
||
async def analysis_find_in_region(
|
||
x1: Annotated[float, "Region minimum X"],
|
||
y1: Annotated[float, "Region minimum Y"],
|
||
x2: Annotated[float, "Region maximum X"],
|
||
y2: Annotated[float, "Region maximum Y"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Find all entities within a rectangular region (crossing selection)."""
|
||
await ctx.info(f"Finding entities in region ({x1},{y1}) → ({x2},{y2})")
|
||
result = await _backend(ctx).analysis_entities_in_region(x1, y1, x2, y2)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Measure Distance", "readOnlyHint": True, "idempotentHint": True},
|
||
tags={"analysis", "measure"},
|
||
)
|
||
async def analysis_measure_distance(
|
||
x1: Annotated[float, "Point 1 X"],
|
||
y1: Annotated[float, "Point 1 Y"],
|
||
x2: Annotated[float, "Point 2 X"],
|
||
y2: Annotated[float, "Point 2 Y"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Measure the Euclidean distance between two points."""
|
||
dist = await _backend(ctx).analysis_measure_distance(x1, y1, x2, y2)
|
||
dx = x2 - x1
|
||
dy = y2 - y1
|
||
angle = math.degrees(math.atan2(dy, dx))
|
||
return {
|
||
"distance": round(dist, 6),
|
||
"dx": round(dx, 6),
|
||
"dy": round(dy, 6),
|
||
"angle_degrees": round(angle, 4),
|
||
}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Measure Area", "readOnlyHint": True, "idempotentHint": True},
|
||
tags={"analysis", "measure"},
|
||
)
|
||
async def analysis_measure_area(
|
||
points: Annotated[list[list[float]], "Polygon vertices as list of [x, y] points (min 3)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Calculate the area of a polygon defined by vertices using the shoelace formula."""
|
||
if len(points) < 3:
|
||
raise ToolError("At least 3 points are required to calculate area.")
|
||
area = await _backend(ctx).analysis_measure_area(points)
|
||
# Also compute perimeter
|
||
perimeter = sum(
|
||
math.sqrt((points[(i+1) % len(points)][0] - points[i][0]) ** 2 +
|
||
(points[(i+1) % len(points)][1] - points[i][1]) ** 2)
|
||
for i in range(len(points))
|
||
)
|
||
return {
|
||
"area": round(area, 6),
|
||
"perimeter": round(perimeter, 6),
|
||
"vertex_count": len(points),
|
||
}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing Bounding Box", "readOnlyHint": True},
|
||
tags={"analysis", "query"},
|
||
)
|
||
async def analysis_bounding_box(ctx: Context = None) -> dict:
|
||
"""Get the bounding box (extents) of all entities in the drawing."""
|
||
return await _backend(ctx).analysis_bounding_box()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Select Entities By Layer", "readOnlyHint": True},
|
||
tags={"analysis", "query"},
|
||
)
|
||
async def analysis_select_by_layer(
|
||
layer_name: Annotated[str, "Layer name to select entities from"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Get all entities on a specific layer. Returns entity list with handles."""
|
||
await ctx.info(f"Selecting all entities on layer '{layer_name}'")
|
||
result = await _backend(ctx).analysis_select_by_layer(layer_name)
|
||
cap = config.settings.max_list_limit
|
||
if len(result) > cap:
|
||
await ctx.warning(f"Layer has {len(result)} entities; truncated to {cap}")
|
||
result = result[:cap]
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Select Entities By Type", "readOnlyHint": True},
|
||
tags={"analysis", "query"},
|
||
)
|
||
async def analysis_select_by_type(
|
||
entity_type: Annotated[str, "Entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE"],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Get all entities of a specific type. Returns entity list with handles."""
|
||
await ctx.info(f"Selecting all {entity_type} entities")
|
||
result = await _backend(ctx).analysis_select_by_type(entity_type)
|
||
cap = config.settings.max_list_limit
|
||
if len(result) > cap:
|
||
await ctx.warning(f"Found {len(result)} entities of type {entity_type}; truncated to {cap}")
|
||
result = result[:cap]
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Layer Statistics", "readOnlyHint": True},
|
||
tags={"analysis", "query", "layer"},
|
||
)
|
||
async def analysis_layer_stats(ctx: Context = None) -> dict:
|
||
"""Return detailed statistics for each layer: entity count, types present."""
|
||
await ctx.info("Computing layer statistics")
|
||
b = _backend(ctx)
|
||
await ctx.report_progress(0, 100)
|
||
layers = await b.layer_list()
|
||
await ctx.report_progress(20, 100)
|
||
all_entities = await b.entity_list(limit=50000)
|
||
await ctx.report_progress(80, 100)
|
||
layer_data: dict[str, dict] = {lyr.name: {"layer": _dc(lyr), "count": 0, "types": {}} for lyr in layers}
|
||
for ent in all_entities:
|
||
lyr_name = ent.layer
|
||
if lyr_name not in layer_data:
|
||
layer_data[lyr_name] = {"layer": {"name": lyr_name}, "count": 0, "types": {}}
|
||
layer_data[lyr_name]["count"] += 1
|
||
t = ent.type
|
||
layer_data[lyr_name]["types"][t] = layer_data[lyr_name]["types"].get(t, 0) + 1
|
||
await ctx.report_progress(100, 100)
|
||
return {
|
||
"layers": list(layer_data.values()),
|
||
"total_layers": len(layer_data),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 8b: Batch Operations (2 tools) ───────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Batch Create Entities", "readOnlyHint": False},
|
||
tags={"entity", "create", "batch"},
|
||
)
|
||
async def entity_batch_create(
|
||
entities: Annotated[list[dict], "List of entity definitions. Each dict must have 'type' and type-specific params. Types: line, circle, arc, polyline, rectangle, text, point"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create multiple entities in a single call for better performance.
|
||
|
||
Each entity dict must have a 'type' key and the parameters for that type.
|
||
Example: [{"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0}, {"type": "circle", "cx": 50, "cy": 50, "radius": 25}]
|
||
"""
|
||
b = _backend(ctx)
|
||
results = []
|
||
errors = []
|
||
total = len(entities)
|
||
await ctx.info(f"Batch creating {total} entities")
|
||
|
||
create_map = {
|
||
"line": b.entity_create_line,
|
||
"circle": b.entity_create_circle,
|
||
"arc": b.entity_create_arc,
|
||
"polyline": b.entity_create_polyline,
|
||
"text": b.entity_create_text,
|
||
"mtext": b.entity_create_mtext,
|
||
"point": b.entity_create_point,
|
||
"hatch": b.entity_create_hatch,
|
||
"spline": b.entity_create_spline,
|
||
"ellipse": b.entity_create_ellipse,
|
||
}
|
||
|
||
for i, ent_def in enumerate(entities):
|
||
await ctx.report_progress(i, total)
|
||
ent_type = ent_def.pop("type", None)
|
||
if not ent_type:
|
||
errors.append({"index": i, "error": "Missing 'type' key"})
|
||
continue
|
||
creator = create_map.get(ent_type.lower())
|
||
if not creator:
|
||
errors.append({"index": i, "error": f"Unknown type: {ent_type}"})
|
||
continue
|
||
try:
|
||
info = await creator(**ent_def)
|
||
results.append(_dc(info))
|
||
except Exception as exc:
|
||
errors.append({"index": i, "type": ent_type, "error": str(exc)})
|
||
|
||
await ctx.report_progress(total, total)
|
||
return {"created": len(results), "errors": errors, "entities": results}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Batch Modify Entities", "readOnlyHint": False},
|
||
tags={"entity", "modify", "batch"},
|
||
)
|
||
async def entity_batch_modify(
|
||
operations: Annotated[list[dict], "List of operations. Each dict: {handle, action, ...params}. Actions: move(dx,dy), rotate(base_x,base_y,angle_deg), scale(base_x,base_y,factor), delete, set_properties(layer,color,...)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Apply multiple modifications in a single call.
|
||
|
||
Example: [{"handle": "1A", "action": "move", "dx": 10, "dy": 20}, {"handle": "2B", "action": "delete"}]
|
||
"""
|
||
b = _backend(ctx)
|
||
results = []
|
||
errors = []
|
||
total = len(operations)
|
||
await ctx.info(f"Batch modifying {total} entities")
|
||
|
||
for i, op in enumerate(operations):
|
||
await ctx.report_progress(i, total)
|
||
handle = op.get("handle")
|
||
action = op.get("action", "").lower()
|
||
if not handle or not action:
|
||
errors.append({"index": i, "error": "Missing 'handle' or 'action'"})
|
||
continue
|
||
try:
|
||
if action == "move":
|
||
await b.entity_move(handle, op.get("dx", 0), op.get("dy", 0), op.get("dz", 0))
|
||
results.append({"handle": handle, "action": "move", "ok": True})
|
||
elif action == "rotate":
|
||
await b.entity_rotate(handle, op["base_x"], op["base_y"], op["angle_deg"])
|
||
results.append({"handle": handle, "action": "rotate", "ok": True})
|
||
elif action == "scale":
|
||
await b.entity_scale(handle, op["base_x"], op["base_y"], op["factor"])
|
||
results.append({"handle": handle, "action": "scale", "ok": True})
|
||
elif action == "delete":
|
||
await b.entity_delete(handle)
|
||
results.append({"handle": handle, "action": "delete", "ok": True})
|
||
elif action == "set_properties":
|
||
await b.entity_set_properties(
|
||
handle,
|
||
layer=op.get("layer"),
|
||
color=op.get("color"),
|
||
linetype=op.get("linetype"),
|
||
lineweight=op.get("lineweight"),
|
||
visible=op.get("visible"),
|
||
)
|
||
results.append({"handle": handle, "action": "set_properties", "ok": True})
|
||
else:
|
||
errors.append({"index": i, "error": f"Unknown action: {action}"})
|
||
except Exception as exc:
|
||
errors.append({"index": i, "handle": handle, "action": action, "error": str(exc)})
|
||
|
||
await ctx.report_progress(total, total)
|
||
return {"modified": len(results), "errors": errors, "results": results}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 8c: Templates (2 tools) ──────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_LAYER_TEMPLATES = {
|
||
"architectural": [
|
||
{"name": "WALLS", "color": 7, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "DOORS", "color": 3, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "WINDOWS", "color": 4, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "FURNITURE", "color": 8, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "DIMENSIONS", "color": 2, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "TEXT", "color": 7, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "GRID", "color": 9, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "HATCHING", "color": 8, "linetype": "Continuous", "lineweight": 13},
|
||
],
|
||
"mechanical": [
|
||
{"name": "VISIBLE", "color": 7, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "HIDDEN", "color": 1, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "CENTER", "color": 3, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "DIMENSIONS", "color": 2, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "SECTION", "color": 5, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "HATCHING", "color": 8, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "PHANTOM", "color": 4, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "ANNOTATIONS", "color": 7, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "BORDER", "color": 7, "linetype": "Continuous", "lineweight": 100},
|
||
],
|
||
"electrical": [
|
||
{"name": "POWER_LINES", "color": 7, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "CONTROL_LINES", "color": 3, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "COMPONENTS", "color": 2, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "TERMINALS", "color": 4, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "WIRE_NUMBERS", "color": 7, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "COMPONENT_TAGS", "color": 8, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "BORDER", "color": 7, "linetype": "Continuous", "lineweight": 100},
|
||
],
|
||
"piping": [
|
||
{"name": "PROCESS_LINES", "color": 7, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "UTILITY_LINES", "color": 3, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "INSTRUMENTS", "color": 2, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "EQUIPMENT", "color": 5, "linetype": "Continuous", "lineweight": 50},
|
||
{"name": "VALVES", "color": 4, "linetype": "Continuous", "lineweight": 25},
|
||
{"name": "TAGS", "color": 7, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "ANNOTATIONS", "color": 8, "linetype": "Continuous", "lineweight": 13},
|
||
{"name": "BORDER", "color": 7, "linetype": "Continuous", "lineweight": 100},
|
||
],
|
||
}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Apply Layer Template", "readOnlyHint": False},
|
||
tags={"template", "layer"},
|
||
)
|
||
async def template_apply_layers(
|
||
template: Annotated[str, "Template name: architectural, mechanical, electrical, piping"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Apply a standard layer set from a predefined template.
|
||
|
||
Available templates: architectural, mechanical, electrical, piping.
|
||
Creates all layers defined in the template with standard colors and lineweights.
|
||
"""
|
||
template_key = template.lower().strip()
|
||
if template_key not in _LAYER_TEMPLATES:
|
||
available = ", ".join(_LAYER_TEMPLATES.keys())
|
||
raise ToolError(f"Unknown template '{template}'. Available: {available}")
|
||
|
||
b = _backend(ctx)
|
||
layers_def = _LAYER_TEMPLATES[template_key]
|
||
created = []
|
||
await ctx.info(f"Applying '{template_key}' layer template ({len(layers_def)} layers)")
|
||
|
||
for ldef in layers_def:
|
||
await b.layer_create(
|
||
ldef["name"],
|
||
color=ldef["color"],
|
||
linetype=ldef["linetype"],
|
||
lineweight=ldef["lineweight"],
|
||
)
|
||
created.append(ldef["name"])
|
||
|
||
return {"ok": True, "template": template_key, "layers_created": created, "count": len(created)}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "List Available Templates", "readOnlyHint": True},
|
||
tags={"template", "query"},
|
||
)
|
||
async def template_list(ctx: Context = None) -> dict:
|
||
"""List all available layer templates and their contents."""
|
||
result = {}
|
||
for name, layers in _LAYER_TEMPLATES.items():
|
||
result[name] = {
|
||
"layer_count": len(layers),
|
||
"layers": [ldef["name"] for ldef in layers],
|
||
}
|
||
return {"templates": result}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 8d: Validation (1 tool) ──────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Validate Drawing", "readOnlyHint": True},
|
||
tags={"analysis", "validation"},
|
||
)
|
||
async def validation_check(
|
||
checks: Annotated[list[str], "List of checks: empty_layers, zero_length, duplicate_entities"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Run quality checks on the current drawing.
|
||
|
||
Available checks:
|
||
- empty_layers: Find layers with no entities
|
||
- zero_length: Find zero-length lines
|
||
- duplicate_entities: Find entities at the same position
|
||
"""
|
||
if checks is None:
|
||
checks = ["empty_layers", "zero_length"]
|
||
|
||
b = _backend(ctx)
|
||
await ctx.info(f"Running validation checks: {', '.join(checks)}")
|
||
issues = []
|
||
|
||
if "empty_layers" in checks:
|
||
layers = await b.layer_list()
|
||
all_entities = await b.entity_list(limit=50000)
|
||
used_layers = {e.layer for e in all_entities}
|
||
for lyr in layers:
|
||
if lyr.name != "0" and lyr.name not in used_layers:
|
||
issues.append({
|
||
"check": "empty_layers",
|
||
"severity": "info",
|
||
"message": f"Layer '{lyr.name}' has no entities",
|
||
"layer": lyr.name,
|
||
})
|
||
|
||
if "zero_length" in checks:
|
||
lines = await b.entity_list(type_filter="LINE", limit=10000)
|
||
for line in lines:
|
||
props = line.properties or {}
|
||
start = props.get("start", [])
|
||
end = props.get("end", [])
|
||
if start and end and len(start) >= 2 and len(end) >= 2:
|
||
dx = end[0] - start[0]
|
||
dy = end[1] - start[1]
|
||
length = (dx * dx + dy * dy) ** 0.5
|
||
if length < 0.001:
|
||
issues.append({
|
||
"check": "zero_length",
|
||
"severity": "warning",
|
||
"message": f"Zero-length line at ({start[0]:.1f}, {start[1]:.1f})",
|
||
"handle": line.handle,
|
||
})
|
||
|
||
return {
|
||
"ok": len(issues) == 0,
|
||
"total_issues": len(issues),
|
||
"issues": issues,
|
||
"checks_run": checks,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 9: View & Screenshot (5 tools) ──────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Zoom Extents", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"view"},
|
||
)
|
||
async def view_zoom_extents(ctx: Context = None) -> dict:
|
||
"""Zoom to show all entities in the drawing (fit drawing in viewport)."""
|
||
return await _backend(ctx).view_zoom_extents()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Zoom Window", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"view"},
|
||
)
|
||
async def view_zoom_window(
|
||
x1: Annotated[float, "Window corner 1 X"],
|
||
y1: Annotated[float, "Window corner 1 Y"],
|
||
x2: Annotated[float, "Window corner 2 X"],
|
||
y2: Annotated[float, "Window corner 2 Y"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Zoom to display the specified rectangular window region."""
|
||
return await _backend(ctx).view_zoom_window(x1, y1, x2, y2)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Screenshot", "readOnlyHint": True},
|
||
tags={"view", "screenshot"},
|
||
)
|
||
async def view_screenshot(ctx: Context = None):
|
||
"""Capture a screenshot of the current drawing view.
|
||
|
||
COM backend: captures live AutoCAD window at current view.
|
||
ezdxf backend: renders via matplotlib to PNG.
|
||
|
||
Returns an Image content block with the PNG data.
|
||
"""
|
||
from fastmcp.utilities.types import Image
|
||
|
||
await ctx.info("Capturing drawing screenshot")
|
||
await ctx.report_progress(0, 100)
|
||
|
||
b = _backend(ctx)
|
||
png_bytes = await b.view_screenshot()
|
||
|
||
await ctx.report_progress(100, 100)
|
||
|
||
if png_bytes is None:
|
||
raise ToolError(
|
||
"Screenshot not available. "
|
||
"For COM backend: ensure AutoCAD window is visible. "
|
||
"For ezdxf backend: install matplotlib (pip install matplotlib)."
|
||
)
|
||
|
||
return Image(data=png_bytes, format="png")
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Zoom and Screenshot", "readOnlyHint": True},
|
||
tags={"view", "screenshot"},
|
||
)
|
||
async def view_zoom_and_screenshot(
|
||
x1: Annotated[float | None, "Optional: zoom to this window corner X1"] = None,
|
||
y1: Annotated[float | None, "Optional: zoom to window corner Y1"] = None,
|
||
x2: Annotated[float | None, "Optional: zoom to window corner X2"] = None,
|
||
y2: Annotated[float | None, "Optional: zoom to window corner Y2"] = None,
|
||
ctx: Context = None,
|
||
):
|
||
"""Zoom to extents (or window if coordinates given), then capture a screenshot.
|
||
|
||
The most useful tool for visually inspecting drawing state.
|
||
"""
|
||
from fastmcp.utilities.types import Image
|
||
|
||
await ctx.info("Zooming and capturing screenshot")
|
||
b = _backend(ctx)
|
||
|
||
await ctx.report_progress(10, 100)
|
||
if x1 is not None and y1 is not None and x2 is not None and y2 is not None:
|
||
await b.view_zoom_window(x1, y1, x2, y2)
|
||
else:
|
||
await b.view_zoom_extents()
|
||
|
||
await ctx.report_progress(50, 100)
|
||
png_bytes = await b.view_screenshot()
|
||
await ctx.report_progress(100, 100)
|
||
|
||
if png_bytes is None:
|
||
raise ToolError("Screenshot unavailable. Check backend capabilities.")
|
||
|
||
return Image(data=png_bytes, format="png")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 10: Transactions (3 tools) ──────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Begin Transaction", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"transaction"},
|
||
)
|
||
async def transaction_begin(ctx: Context = None) -> dict:
|
||
"""Begin a transaction (undo mark).
|
||
|
||
COM backend: Sets AutoCAD undo mark. All subsequent operations can be
|
||
rolled back to this point with transaction_rollback.
|
||
|
||
ezdxf backend: Saves a DXF snapshot. Rollback restores the full document
|
||
state to this point.
|
||
|
||
Always pair with transaction_commit or transaction_rollback.
|
||
"""
|
||
await ctx.info("Beginning transaction")
|
||
return await _backend(ctx).transaction_begin()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Commit Transaction", "readOnlyHint": False, "destructiveHint": False},
|
||
tags={"transaction"},
|
||
)
|
||
async def transaction_commit(ctx: Context = None) -> dict:
|
||
"""Commit the current transaction.
|
||
|
||
COM: Ends the undo mark (changes are permanent but still undoable via drawing_undo).
|
||
ezdxf: Discards the rollback snapshot (changes are kept).
|
||
"""
|
||
await ctx.info("Committing transaction")
|
||
return await _backend(ctx).transaction_commit()
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Rollback Transaction", "readOnlyHint": False, "destructiveHint": True},
|
||
tags={"transaction"},
|
||
)
|
||
async def transaction_rollback(ctx: Context = None) -> dict:
|
||
"""Rollback the current transaction to the point of transaction_begin.
|
||
|
||
COM: Undoes all operations back to the last undo mark.
|
||
ezdxf: Restores the document from the saved DXF snapshot.
|
||
|
||
WARNING: This is destructive – all changes since transaction_begin are lost.
|
||
"""
|
||
await ctx.warning("Rolling back transaction")
|
||
return await _backend(ctx).transaction_rollback()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 11: System (6 tools) ────────────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Server Status", "readOnlyHint": True},
|
||
tags={"system"},
|
||
)
|
||
async def system_status(ctx: Context = None) -> dict:
|
||
"""Get full status of the AutoCAD MCP Pro server and backend connection.
|
||
|
||
Returns backend name, connection status, capabilities, document info.
|
||
"""
|
||
b = ctx.lifespan_context.get("backend")
|
||
tool_count = _registered_tool_count()
|
||
unsafe = config.settings.dangerous_commands_enabled
|
||
if b is None:
|
||
return {
|
||
"server": "AutoCAD MCP Pro",
|
||
"backend": "none",
|
||
"connected": False,
|
||
"tool_count": tool_count,
|
||
"unsafe_mode": unsafe,
|
||
"error": ctx.lifespan_context.get("init_error"),
|
||
"hint": "Set AUTOCAD_MCP_BACKEND=ezdxf to use headless mode, or start AutoCAD for COM mode.",
|
||
}
|
||
status = await b.system_status()
|
||
status["server"] = "AutoCAD MCP Pro"
|
||
status["tool_count"] = tool_count
|
||
status["unsafe_mode"] = unsafe
|
||
if unsafe:
|
||
status["unsafe_mode_warning"] = (
|
||
"DANGEROUS_COMMANDS_ENABLED=true — command/LISP sanitization disabled."
|
||
)
|
||
return status
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Get System Variable", "readOnlyHint": True},
|
||
tags={"system"},
|
||
)
|
||
async def system_get_variable(
|
||
name: Annotated[str, "System variable name (e.g. DIMSCALE, LTSCALE, INSUNITS, CLAYER, MEASUREMENT)"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Get an AutoCAD system variable value."""
|
||
value = await _backend(ctx).system_get_variable(name)
|
||
return {"variable": name, "value": value}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Set System Variable", "readOnlyHint": False},
|
||
tags={"system"},
|
||
)
|
||
async def system_set_variable(
|
||
name: Annotated[str, "System variable name"],
|
||
value: Annotated[Any, "New variable value"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT)."""
|
||
return await _backend(ctx).system_set_variable(name, value)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Run AutoCAD Command", "readOnlyHint": False},
|
||
tags={"system"},
|
||
)
|
||
async def system_run_command(
|
||
command: Annotated[str, "AutoCAD command string (e.g. '_ZOOM E', '_REGEN', '_EXPLODE')"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Execute an AutoCAD command string directly (COM backend only).
|
||
|
||
Append \\n for Enter. Example: '_LINE 0,0 100,0 \\n'.
|
||
|
||
IMPORTANT: commands that finish at an option menu (e.g. -LINETYPE, -LAYER,
|
||
-STYLE return to '[?/Create/Load/Set]:' after their action) need an EXTRA
|
||
blank line or '_X\\n' to exit, otherwise AutoCAD stays at a prompt and the
|
||
next COM call will deadlock. Example: '_-LINETYPE _LOAD CENTER acad.lin\\n\\n'.
|
||
"""
|
||
sanitize_command(command)
|
||
await ctx.warning(f"Running command: {command}")
|
||
return await _backend(ctx).system_run_command(command)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Execute AutoLISP", "readOnlyHint": False},
|
||
tags={"system"},
|
||
)
|
||
async def system_run_lisp(
|
||
expression: Annotated[str, "AutoLISP expression to evaluate (e.g. '(command \"ZOOM\" \"E\")')"],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Execute an AutoLISP expression (COM backend only).
|
||
|
||
Example: '(setvar \"DIMSCALE\" 1.0)'
|
||
"""
|
||
sanitize_lisp(expression)
|
||
await ctx.warning(f"Running LISP: {expression[:80]}")
|
||
return await _backend(ctx).system_run_lisp(expression)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Backend Info", "readOnlyHint": True},
|
||
tags={"system"},
|
||
)
|
||
async def system_about(ctx: Context = None) -> dict:
|
||
"""Get detailed information about AutoCAD MCP Pro capabilities and available tools."""
|
||
b = ctx.lifespan_context.get("backend")
|
||
backend_name = b.name if b else "none"
|
||
return {
|
||
"name": "AutoCAD MCP Pro",
|
||
"version": "1.0.0",
|
||
"description": "Production-grade AutoCAD MCP server with dual COM+ezdxf engine",
|
||
"active_backend": backend_name,
|
||
"tool_groups": {
|
||
"drawing": ["drawing_info", "drawing_new", "drawing_open", "drawing_save",
|
||
"drawing_save_as", "drawing_export_dxf", "drawing_export_pdf",
|
||
"drawing_purge", "drawing_audit", "drawing_undo", "drawing_redo"],
|
||
"entity_creation": ["entity_create_line", "entity_create_circle", "entity_create_arc",
|
||
"entity_create_polyline", "entity_create_rectangle",
|
||
"entity_create_text", "entity_create_mtext", "entity_create_hatch",
|
||
"entity_create_spline", "entity_create_ellipse",
|
||
"entity_create_point", "entity_create_block_ref",
|
||
"entity_delete_many"],
|
||
"dimensions": ["dimension_linear", "dimension_aligned", "dimension_angular",
|
||
"dimension_radius", "dimension_diameter"],
|
||
"entity_modification": ["entity_move", "entity_copy", "entity_rotate", "entity_scale",
|
||
"entity_mirror", "entity_offset", "entity_delete",
|
||
"entity_array_rectangular", "entity_array_polar",
|
||
"entity_set_properties"],
|
||
"entity_query": ["entity_get", "entity_list"],
|
||
"batch": ["entity_batch_create", "entity_batch_modify"],
|
||
"templates": ["template_apply_layers", "template_list"],
|
||
"layers": ["layer_list", "layer_create", "layer_delete", "layer_set_current",
|
||
"layer_modify", "layer_freeze", "layer_thaw", "layer_lock",
|
||
"layer_unlock", "layer_hide", "layer_show", "layer_isolate",
|
||
"linetype_list", "linetype_load"],
|
||
"blocks": ["block_list", "block_insert", "block_explode",
|
||
"block_get_attributes", "block_set_attributes",
|
||
"block_create_from_entities", "block_find_references"],
|
||
"analysis": ["analysis_entity_stats", "analysis_find_in_region",
|
||
"analysis_measure_distance", "analysis_measure_area",
|
||
"analysis_bounding_box", "analysis_select_by_layer",
|
||
"analysis_select_by_type", "analysis_layer_stats"],
|
||
"validation": ["validation_check"],
|
||
"view": ["view_zoom_extents", "view_zoom_window", "view_screenshot",
|
||
"view_zoom_and_screenshot"],
|
||
"transactions": ["transaction_begin", "transaction_commit", "transaction_rollback"],
|
||
"system": ["system_status", "system_get_variable", "system_set_variable",
|
||
"system_run_command", "system_run_lisp", "system_about"],
|
||
},
|
||
"total_tools": _registered_tool_count(),
|
||
"unsafe_mode": config.settings.dangerous_commands_enabled,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 12: Engineering Tools (deterministic CAD generators) ────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Gear: Helical Front View", "destructiveHint": False},
|
||
tags={"engineering", "gear"},
|
||
)
|
||
async def gear_draw_helical_front_view(
|
||
module: Annotated[float, Field(gt=0, description="Module (mm). Pitch radius = module*teeth/2.")],
|
||
teeth: Annotated[int, Field(ge=6, description="Number of teeth.")],
|
||
helix_angle: Annotated[float, Field(ge=0, lt=45, description="Helix angle in degrees.")],
|
||
pressure_angle: Annotated[float, Field(default=20.0, gt=0, lt=45,
|
||
description="Pressure angle (deg). Standard: 20.")] = 20.0,
|
||
hand: Annotated[str, Field(default="RH",
|
||
description="Helix hand: 'RH' (right) or 'LH' (left).")] = "RH",
|
||
center_x: Annotated[float, Field(default=0.0)] = 0.0,
|
||
center_y: Annotated[float, Field(default=0.0)] = 0.0,
|
||
bore_diameter: Annotated[float | None, Field(default=None, gt=0,
|
||
description="Optional bore diameter (mm). Adds a centered hole.")] = None,
|
||
keyway_width: Annotated[float | None, Field(default=None, gt=0,
|
||
description="Keyway width (b). Auto from DIN 6885 if bore set and this is None.")] = None,
|
||
keyway_depth: Annotated[float | None, Field(default=None, gt=0,
|
||
description="Keyway depth into hub (t2).")] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Deterministic helical gear front view: full involute outline (40 pts/flank),
|
||
pitch/base/outer/root circles, helix symbol, optional bore + keyway.
|
||
|
||
Returns a handle bundle plus 'metadata' for downstream gear_draw_section_aa.
|
||
"""
|
||
from engineering import draw_helical_gear_front_view
|
||
backend = _backend(ctx)
|
||
await ctx.info(f"Drawing helical gear: m={module}, z={teeth}, beta={helix_angle} deg, hand={hand}")
|
||
return await draw_helical_gear_front_view(
|
||
backend, module=module, teeth=teeth, helix_angle=helix_angle,
|
||
pressure_angle=pressure_angle, hand=hand, center=(center_x, center_y),
|
||
bore_diameter=bore_diameter, keyway_width=keyway_width, keyway_depth=keyway_depth,
|
||
)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Gear: Spur Front View", "destructiveHint": False},
|
||
tags={"engineering", "gear"},
|
||
)
|
||
async def gear_draw_spur_front_view(
|
||
module: Annotated[float, Field(gt=0)],
|
||
teeth: Annotated[int, Field(ge=6)],
|
||
pressure_angle: Annotated[float, Field(default=20.0, gt=0, lt=45)] = 20.0,
|
||
center_x: Annotated[float, Field(default=0.0)] = 0.0,
|
||
center_y: Annotated[float, Field(default=0.0)] = 0.0,
|
||
bore_diameter: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Deterministic spur gear front view (no helix symbol)."""
|
||
from engineering import draw_spur_gear_front_view
|
||
backend = _backend(ctx)
|
||
await ctx.info(f"Drawing spur gear: m={module}, z={teeth}")
|
||
return await draw_spur_gear_front_view(
|
||
backend, module=module, teeth=teeth, pressure_angle=pressure_angle,
|
||
center=(center_x, center_y), bore_diameter=bore_diameter,
|
||
keyway_width=keyway_width, keyway_depth=keyway_depth,
|
||
)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Gear: Section A-A View", "destructiveHint": False},
|
||
tags={"engineering", "gear"},
|
||
)
|
||
async def gear_draw_section_aa(
|
||
gear_metadata: Annotated[dict, Field(description="The 'metadata' dict returned by gear_draw_helical_front_view or gear_draw_spur_front_view.")],
|
||
x_offset: Annotated[float, Field(description="X position to place the section view.")],
|
||
face_width: Annotated[float, Field(gt=0, description="Gear face width (mm).")],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Deterministic side cross-section of a gear created by gear_draw_*_front_view.
|
||
Includes top/bottom/left/right boundaries, bore lines, keyway notch, ANSI31 hatch.
|
||
"""
|
||
from engineering import draw_gear_section_aa
|
||
backend = _backend(ctx)
|
||
await ctx.info(f"Drawing section A-A at x={x_offset}, face_width={face_width}")
|
||
return await draw_gear_section_aa(
|
||
backend, gear_metadata=gear_metadata, x_offset=x_offset, face_width=face_width,
|
||
)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Keyway: Keyed Bore (front view)", "destructiveHint": False},
|
||
tags={"engineering", "keyway"},
|
||
)
|
||
async def keyway_draw_keyed_bore(
|
||
center_x: Annotated[float, Field()],
|
||
center_y: Annotated[float, Field()],
|
||
bore_diameter: Annotated[float, Field(gt=0)],
|
||
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
layer: Annotated[str, Field(default="GEOMETRY")] = "GEOMETRY",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Bore + DIN 6885 keyway in front view. Auto-sizes keyway from bore if width/depth omitted."""
|
||
from engineering import draw_keyed_bore
|
||
backend = _backend(ctx)
|
||
return await draw_keyed_bore(
|
||
backend, center=(center_x, center_y), bore_diameter=bore_diameter,
|
||
keyway_width=keyway_width, keyway_depth=keyway_depth, layer=layer,
|
||
)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Keyway: Side Section", "destructiveHint": False},
|
||
tags={"engineering", "keyway"},
|
||
)
|
||
async def keyway_draw_section(
|
||
center_x: Annotated[float, Field()],
|
||
center_y: Annotated[float, Field()],
|
||
bore_diameter: Annotated[float, Field(gt=0)],
|
||
face_width: Annotated[float, Field(gt=0)],
|
||
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Side cross-section view of a keyed bore."""
|
||
from engineering import draw_keyway_section
|
||
backend = _backend(ctx)
|
||
return await draw_keyway_section(
|
||
backend, center=(center_x, center_y), bore_diameter=bore_diameter,
|
||
face_width=face_width, keyway_width=keyway_width, keyway_depth=keyway_depth,
|
||
)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "TitleBlock: ISO A3", "destructiveHint": False},
|
||
tags={"engineering", "titleblock"},
|
||
)
|
||
async def titleblock_apply_iso_a3(
|
||
title: Annotated[str, Field(description="Drawing title (verbatim, no LLM transformation).")],
|
||
drawing_no: Annotated[str, Field(description="Drawing number (e.g. 'AM-2026-001').")],
|
||
part_no: Annotated[str, Field(default="")] = "",
|
||
material: Annotated[str, Field(default="")] = "",
|
||
scale: Annotated[str, Field(default="1:1")] = "1:1",
|
||
units: Annotated[str, Field(default="mm")] = "mm",
|
||
drawn_by: Annotated[str, Field(default="")] = "",
|
||
checked_by: Annotated[str, Field(default="")] = "",
|
||
date: Annotated[str, Field(default="")] = "",
|
||
sheet: Annotated[str, Field(default="1/1")] = "1/1",
|
||
revision: Annotated[str, Field(default="A")] = "A",
|
||
company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine",
|
||
origin_x: Annotated[float, Field(default=0.0)] = 0.0,
|
||
origin_y: Annotated[float, Field(default=0.0)] = 0.0,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim."""
|
||
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
|
||
backend = _backend(ctx)
|
||
metadata = TitleBlockMetadata(
|
||
title=title, drawing_no=drawing_no, part_no=part_no, material=material,
|
||
scale=scale, units=units, drawn_by=drawn_by, checked_by=checked_by,
|
||
date=date, sheet=sheet, revision=revision, company=company,
|
||
)
|
||
return await apply_iso_a3_titleblock(backend, metadata=metadata, origin=(origin_x, origin_y))
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing: Finalize (validate + save + screenshot)",
|
||
"destructiveHint": True},
|
||
tags={"engineering", "drawing", "validation"},
|
||
)
|
||
async def drawing_finalize(
|
||
save_path: Annotated[str | None, Field(default=None,
|
||
description="If given, save drawing here before validation. Pass full path including extension.")] = None,
|
||
screenshot_path: Annotated[str | None, Field(default=None,
|
||
description="If given, write PNG screenshot to this path.")] = None,
|
||
expected: Annotated[dict | None, Field(default=None,
|
||
description="Optional contract: {'part_type', 'helix_angle', 'must_have_bore', 'must_have_keyway'}")] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""8-step pre-completion validator: real dimensions, no hidden orphans, linetypes loaded,
|
||
bore+keyway in both views (if expected), title consistency, saved to disk, screenshot exported,
|
||
DWG path returned. Raises ToolError if any 'error' severity finding is present.
|
||
"""
|
||
from engineering import DrawingValidator
|
||
backend = _backend(ctx)
|
||
|
||
if save_path:
|
||
validated = validate_path(save_path, allow_write=True)
|
||
from pathlib import Path as _P
|
||
_fmt = _P(str(validated)).suffix.lstrip(".").lower() or "dxf"
|
||
await backend.drawing_save_as(str(validated), fmt=_fmt)
|
||
|
||
if screenshot_path:
|
||
validated_shot = validate_path(screenshot_path, allow_write=True)
|
||
try:
|
||
shot = await backend.view_screenshot()
|
||
if shot:
|
||
from pathlib import Path as _P
|
||
_P(str(validated_shot)).write_bytes(shot)
|
||
except Exception as exc:
|
||
await ctx.warning(f"Screenshot export failed: {exc}")
|
||
|
||
result = await DrawingValidator().run(backend, expected=expected or {})
|
||
payload = result.to_dict()
|
||
info = await backend.drawing_info()
|
||
payload["dwg_path"] = getattr(info, "full_path", "")
|
||
if not result.ok:
|
||
raise ToolError(
|
||
f"drawing_finalize: validation failed with {result.summary['error']} error(s). "
|
||
f"First: {result.findings[0].code}: {result.findings[0].message}"
|
||
)
|
||
return payload
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 13: Premium Drafting Meta-Tools (5x quality multiplier) ─────────
|
||
# ---------------------------------------------------------------------------
|
||
# These tools wrap the entity primitives in a quality-first workflow:
|
||
# plan → snap-aware geometry → critique → finalize.
|
||
# See `.claude/skills/autocad-mcp-premium/` for the full discipline.
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing: Plan (commit intent before drawing)",
|
||
"destructiveHint": False},
|
||
tags={"premium", "planning"},
|
||
)
|
||
async def drawing_plan(
|
||
intent: Annotated[str, "One-line description of what this drawing represents."],
|
||
sheet_size: Annotated[str, Field(default="A3",
|
||
description="Paper size: A4 / A3 / A2 / A1 / A0.")] = "A3",
|
||
scale: Annotated[float, Field(default=1.0, gt=0,
|
||
description="Drawing scale (1.0 = 1:1, 0.1 = 1:10, etc).")] = 1.0,
|
||
layer_set_id: Annotated[str, Field(default="mech",
|
||
description="Layer set to bootstrap: mech / pid / iso13567.")] = "mech",
|
||
view_count: Annotated[int, Field(default=1, ge=1)] = 1,
|
||
dim_style: Annotated[str, Field(default="chain",
|
||
description="Default dimensioning style: chain / baseline / ordinate / mixed.")] = "chain",
|
||
notes: Annotated[list[str] | None, "Free-form constraint notes."] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Commit a PlanSpec before any geometry is created.
|
||
|
||
The PlanSpec is held by the backend and replayed by `drawing_critique`
|
||
at finalize time to verify the drawing matches the original intent.
|
||
Always call this FIRST in a premium workflow.
|
||
"""
|
||
plan = await _backend(ctx).drawing_plan(
|
||
intent, sheet_size, scale, layer_set_id, view_count, dim_style, notes,
|
||
)
|
||
return _dc(plan)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing: Critique (premium quality checks)",
|
||
"destructiveHint": False, "readOnlyHint": True},
|
||
tags={"premium", "validation"},
|
||
)
|
||
async def drawing_critique(
|
||
focus: Annotated[list[str] | None, Field(default=None,
|
||
description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, "
|
||
"duplicate_entities, construction_left. None = run all.")] = None,
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Run premium-quality checks. Returns zero issues for a clean drawing.
|
||
|
||
Standard production gate: must return [] before `drawing_finalize`.
|
||
"""
|
||
issues = await _backend(ctx).drawing_critique(focus)
|
||
return [_dc(i) for i in issues]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Point: Snap (deterministic OSNAP)", "readOnlyHint": True},
|
||
tags={"premium", "snap"},
|
||
)
|
||
async def point_from_snap(
|
||
handle: Annotated[str, "Entity handle to snap onto"],
|
||
snap: Annotated[str, Field(description="Snap type: end | mid | center | quad | perp | near")],
|
||
ref_x: Annotated[float | None, "Reference X (required for perp/near; disambiguates end/quad)"] = None,
|
||
ref_y: Annotated[float | None, "Reference Y"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Compute a deterministic snap point on an entity. Use this INSTEAD OF
|
||
guessing coordinates — eliminates the most common LLM drawing error.
|
||
"""
|
||
pt = await _backend(ctx).point_from_snap(handle, snap, ref_x, ref_y)
|
||
return {"x": float(pt[0]), "y": float(pt[1])}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Point: Intersection (deterministic)", "readOnlyHint": True},
|
||
tags={"premium", "snap"},
|
||
)
|
||
async def point_intersection(
|
||
handle1: Annotated[str, "First entity handle (LINE or CIRCLE)"],
|
||
handle2: Annotated[str, "Second entity handle (LINE or CIRCLE)"],
|
||
ref_x: Annotated[float | None, "Reference X to pick nearest candidate when multiple exist"] = None,
|
||
ref_y: Annotated[float | None, "Reference Y"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Compute the intersection of two geometry entities (LINE-LINE, LINE-CIRCLE,
|
||
CIRCLE-CIRCLE). When two candidates exist, ref_x/ref_y selects the nearest.
|
||
Returns {x, y}.
|
||
"""
|
||
pt = await _backend(ctx).point_intersection(handle1, handle2, ref_x, ref_y)
|
||
return {"x": float(pt[0]), "y": float(pt[1])}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Point: Tangent from external point", "readOnlyHint": True},
|
||
tags={"premium", "snap"},
|
||
)
|
||
async def point_tangent(
|
||
circle_handle: Annotated[str, "Handle of the CIRCLE entity"],
|
||
from_x: Annotated[float, "X of the external point"],
|
||
from_y: Annotated[float, "Y of the external point"],
|
||
ref_x: Annotated[float | None, "Reference X to pick nearest tangent point when two exist"] = None,
|
||
ref_y: Annotated[float | None, "Reference Y"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Compute the tangent point on a circle from an external point.
|
||
Returns {x, y}. Raises if the from-point is inside the circle.
|
||
"""
|
||
pt = await _backend(ctx).point_tangent(circle_handle, from_x, from_y, ref_x, ref_y)
|
||
return {"x": float(pt[0]), "y": float(pt[1])}
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Construction: XLine (infinite reference)",
|
||
"destructiveHint": False},
|
||
tags={"premium", "construction"},
|
||
)
|
||
async def construction_xline(
|
||
x: Annotated[float, "Base point X"],
|
||
y: Annotated[float, "Base point Y"],
|
||
angle_deg: Annotated[float, "Angle in degrees (0=horizontal, 90=vertical)"],
|
||
layer: Annotated[str, "Layer for the construction line"] = "CONSTRUCTION",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create an infinite construction line on the CONSTRUCTION layer.
|
||
Use as scaffolding; call `construction_clear()` before finalize.
|
||
"""
|
||
result = await _backend(ctx).construction_xline(x, y, angle_deg, layer)
|
||
return _dc(result)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Construction: Clear (delete scaffold)",
|
||
"destructiveHint": True},
|
||
tags={"premium", "construction"},
|
||
)
|
||
async def construction_clear(
|
||
layer: Annotated[str, "Layer to clear"] = "CONSTRUCTION",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Delete every entity on the CONSTRUCTION layer. Idempotent.
|
||
Must be called before `drawing_finalize` to satisfy `construction_left` critique.
|
||
"""
|
||
return await _backend(ctx).construction_clear(layer)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Drawing: Apply ISO Layer Set (bootstrap)",
|
||
"destructiveHint": False},
|
||
tags={"premium", "layers"},
|
||
)
|
||
async def drawing_apply_iso_layers(
|
||
standard: Annotated[str, Field(default="mech",
|
||
description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).")] = "mech",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Bootstrap a full ISO-conformant layer set with correct colors and lineweights.
|
||
Idempotent — existing layers are not modified.
|
||
"""
|
||
return await _backend(ctx).drawing_apply_iso_layers(standard)
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Dimension: Auto (chain / baseline / ordinate)",
|
||
"destructiveHint": False},
|
||
tags={"premium", "dimension"},
|
||
)
|
||
async def dimension_auto(
|
||
handles: Annotated[list[str], "List of entity handles to dimension"],
|
||
style: Annotated[str, Field(default="chain",
|
||
description="chain | baseline | ordinate")] = "chain",
|
||
offset: Annotated[float, Field(default=10.0, gt=0,
|
||
description="Dimension-line offset from the geometry (mm)")] = 10.0,
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Generate ISO 129 dimensions across the listed entities in the chosen style.
|
||
V1 supports LINE entities only.
|
||
"""
|
||
result = await _backend(ctx).dimension_auto(handles, style, offset)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
@mcp.tool(
|
||
annotations={"title": "Entity: Smart Select (semantic predicate)",
|
||
"readOnlyHint": True},
|
||
tags={"premium", "select"},
|
||
)
|
||
async def entity_select_smart(
|
||
predicate: Annotated[dict, Field(description=(
|
||
"Predicate dict (all keys optional, AND-ed): "
|
||
"type (e.g. 'LINE'), layer (name), near ([x,y,radius]), "
|
||
"length_range ([min,max], LINE/ARC only), color (ACI int)."
|
||
))],
|
||
ctx: Context = None,
|
||
) -> list[dict]:
|
||
"""Select entities by semantic predicate instead of memorising handles."""
|
||
result = await _backend(ctx).entity_select_smart(predicate)
|
||
return [_dc(e) for e in result]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.resource(
|
||
"autocad://drawing/info",
|
||
name="Current Drawing Info",
|
||
description="Metadata for the currently open drawing",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"drawing"},
|
||
)
|
||
async def resource_drawing_info(ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"error": "Backend not ready"})
|
||
try:
|
||
info = await b.drawing_info()
|
||
return json.dumps(_dc(info), indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://layers",
|
||
name="Layer List",
|
||
description="All layers in the current drawing with properties",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"layer"},
|
||
)
|
||
async def resource_layers(ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"error": "Backend not ready"})
|
||
try:
|
||
layers = await b.layer_list()
|
||
return json.dumps([_dc(lyr) for lyr in layers], indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://blocks",
|
||
name="Block Library",
|
||
description="All block definitions in the current drawing",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"block"},
|
||
)
|
||
async def resource_blocks(ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"error": "Backend not ready"})
|
||
try:
|
||
blocks = await b.block_list()
|
||
return json.dumps([_dc(blk) for blk in blocks], indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://entities/stats",
|
||
name="Entity Statistics",
|
||
description="Entity counts by type and layer",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"analysis"},
|
||
)
|
||
async def resource_entity_stats(ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"error": "Backend not ready"})
|
||
try:
|
||
stats = await b.analysis_stats()
|
||
return json.dumps(stats, indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://system/status",
|
||
name="Server Status",
|
||
description="AutoCAD MCP Pro server and backend status",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"system"},
|
||
)
|
||
async def resource_status(ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"backend": "none", "connected": False})
|
||
try:
|
||
status = await b.system_status()
|
||
return json.dumps(status, indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://entities/{layer_name}",
|
||
name="Entities By Layer",
|
||
description="List all entities on a specific layer",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"entity", "layer"},
|
||
)
|
||
async def resource_entities_by_layer(layer_name: str, ctx: Context = None) -> str:
|
||
b = ctx.lifespan_context.get("backend")
|
||
if b is None:
|
||
return json.dumps({"error": "Backend not ready"})
|
||
try:
|
||
entities = await b.analysis_select_by_layer(layer_name)
|
||
return json.dumps([_dc(e) for e in entities], indent=2)
|
||
except Exception as exc:
|
||
log.debug("Resource error: %s", exc)
|
||
return json.dumps({"error": str(exc)})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ── PROMPTS ──────────────────────────────────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@mcp.prompt(tags={"template", "architectural"})
|
||
def prompt_floor_plan(
|
||
building_name: str = "Building A",
|
||
scale: str = "1:100",
|
||
units: str = "mm",
|
||
) -> str:
|
||
"""Generate a prompt for creating a floor plan drawing."""
|
||
return f"""You are creating a floor plan for '{building_name}' at scale {scale} in {units}.
|
||
|
||
LAYER SETUP (create these layers first):
|
||
- WALLS color=7 (white) linetype=Continuous lineweight=50
|
||
- DOORS color=3 (green) linetype=Continuous lineweight=25
|
||
- WINDOWS color=4 (cyan) linetype=Continuous lineweight=25
|
||
- FURNITURE color=8 (gray) linetype=Continuous lineweight=13
|
||
- DIMENSIONS color=2 (yellow) linetype=Continuous lineweight=13
|
||
- TEXT color=7 (white) linetype=Continuous lineweight=13
|
||
- GRID color=9 linetype=DASHED lineweight=13
|
||
|
||
WORKFLOW:
|
||
1. drawing_new() → create new drawing
|
||
2. layer_create() for each layer above
|
||
3. entity_create_rectangle() on WALLS layer for outer boundary
|
||
4. entity_create_polyline() on WALLS for interior walls (width ~200mm)
|
||
5. entity_create_arc() on DOORS for door swings (radius=900mm)
|
||
6. entity_create_rectangle() on WINDOWS for window openings
|
||
7. dimension_aligned() on DIMENSIONS layer for key measurements
|
||
8. entity_create_text() on TEXT layer for room labels
|
||
9. view_zoom_and_screenshot() to verify layout
|
||
|
||
CONVENTIONS:
|
||
- Exterior walls: 300mm thick
|
||
- Interior walls: 150mm thick
|
||
- Door openings: 900mm wide
|
||
- Window sills: 900mm from floor (not shown in plan)
|
||
- Room labels: centered in each space, height=300mm at 1:100
|
||
"""
|
||
|
||
|
||
@mcp.prompt(tags={"template", "pid"})
|
||
def prompt_pid_diagram(
|
||
project_name: str = "Process Unit 01",
|
||
revision: str = "Rev A",
|
||
) -> str:
|
||
"""Generate a prompt for creating a P&ID (Piping and Instrumentation Diagram)."""
|
||
return f"""You are creating a P&ID for '{project_name}' ({revision}).
|
||
|
||
LAYER SETUP:
|
||
- PROCESS_LINES color=7 lineweight=50 (main process piping)
|
||
- UTILITY_LINES color=3 lineweight=25 (utility services)
|
||
- INSTRUMENTS color=2 lineweight=25 (instrument circles)
|
||
- EQUIPMENT color=5 lineweight=50 (vessels, pumps, HX)
|
||
- VALVES color=4 lineweight=25 (valve symbols)
|
||
- TAGS color=7 lineweight=13 (tag numbers)
|
||
- ANNOTATIONS color=8 lineweight=13 (notes)
|
||
- BORDER color=7 lineweight=100 (drawing border)
|
||
|
||
STANDARD SYMBOLS (draw as entities):
|
||
- Vessels: rectangle with domed ends
|
||
- Pumps: circle with triangle (impeller)
|
||
- Heat Exchangers: two overlapping rectangles
|
||
- Valves: two triangles point-to-point
|
||
- Control valves: valve symbol + circle above
|
||
- Instruments: circle with tag number
|
||
|
||
INSTRUMENT TAG FORMAT: [Function][Loop Number][Suffix]
|
||
Examples: FT-101 (flow transmitter), FIC-101 (flow indicator controller)
|
||
|
||
WORKFLOW:
|
||
1. layer_create() for all layers
|
||
2. entity_create_rectangle() for drawing border
|
||
3. Place major equipment first (vessels, columns)
|
||
4. Draw process lines (polylines) connecting equipment
|
||
5. Place valve symbols at control points
|
||
6. Add instrument bubbles (circles + text)
|
||
7. Add line numbers and stream labels
|
||
8. Add title block text
|
||
"""
|
||
|
||
|
||
@mcp.prompt(tags={"template", "electrical"})
|
||
def prompt_electrical_schematic(
|
||
circuit_name: str = "Main Distribution Panel",
|
||
voltage: str = "400V/230V",
|
||
) -> str:
|
||
"""Generate a prompt for creating an electrical schematic diagram."""
|
||
return f"""You are creating an electrical schematic for '{circuit_name}' at {voltage}.
|
||
|
||
LAYER SETUP:
|
||
- POWER_LINES color=7 lineweight=50
|
||
- CONTROL_LINES color=3 lineweight=25
|
||
- COMPONENTS color=2 lineweight=25
|
||
- TERMINALS color=4 lineweight=25
|
||
- WIRE_NUMBERS color=7 lineweight=13
|
||
- COMPONENT_TAGS color=8 lineweight=13
|
||
- BORDER color=7 lineweight=100
|
||
|
||
STANDARD IEC 60617 SYMBOLS:
|
||
- Circuit breaker: rectangle with diagonal line
|
||
- Contactor: circle with cross
|
||
- Relay coil: rectangle
|
||
- Motor: circle with 'M'
|
||
- Fuse: rectangle with horizontal line
|
||
- Switch NO: two points with gap
|
||
- Switch NC: two points with diagonal slash
|
||
|
||
LADDER DIAGRAM CONVENTIONS:
|
||
- Power rails: vertical lines on left (L1/L2/L3) and right (N/PE)
|
||
- Rungs: horizontal lines connecting rails
|
||
- Load elements (coils, motors): always on right side of rung
|
||
- Contact elements: always to the left of loads
|
||
- Rung numbers: on left margin
|
||
|
||
WORKFLOW:
|
||
1. layer_create() for all layers
|
||
2. entity_create_line() for power rails (vertical)
|
||
3. entity_create_polyline() for each circuit rung
|
||
4. Place component symbols with entity_create_*
|
||
5. Add wire numbers as text entities
|
||
6. Add component reference tags
|
||
7. view_zoom_and_screenshot() to verify
|
||
"""
|
||
|
||
|
||
@mcp.prompt(tags={"template", "mechanical"})
|
||
def prompt_mechanical_drawing(
|
||
part_name: str = "Part-001",
|
||
material: str = "Steel",
|
||
scale: str = "1:1",
|
||
) -> str:
|
||
"""Generate a prompt for creating a mechanical engineering drawing."""
|
||
return f"""You are creating a mechanical drawing for '{part_name}', material: {material}, scale: {scale}.
|
||
|
||
LAYER SETUP (ISO 128 standards):
|
||
- VISIBLE color=7 linetype=Continuous lineweight=50 (visible edges)
|
||
- HIDDEN color=1 linetype=DASHED lineweight=25 (hidden edges)
|
||
- CENTER color=3 linetype=CENTER lineweight=13 (center lines)
|
||
- DIMENSIONS color=2 linetype=Continuous lineweight=13 (dimensions)
|
||
- SECTION color=5 linetype=Continuous lineweight=50 (section lines)
|
||
- HATCHING color=8 linetype=Continuous lineweight=13 (section hatching)
|
||
- PHANTOM color=4 linetype=PHANTOM lineweight=13 (phantom lines)
|
||
- ANNOTATIONS color=7 linetype=Continuous lineweight=13 (notes)
|
||
- BORDER color=7 linetype=Continuous lineweight=100 (border/title block)
|
||
|
||
DRAWING STANDARDS:
|
||
- Third-angle projection (ASME) or First-angle (ISO)
|
||
- Center lines extend 3-5mm beyond feature
|
||
- Dimension lines: offset 8-10mm from feature
|
||
- Leader lines: 60° angle preferred
|
||
- Tolerance notation: ±0.1 general, tighter for fits
|
||
- Surface finish: Ra values in µm
|
||
- Title block: part number, revision, scale, material, drawn by, date
|
||
|
||
VIEW LAYOUT (for standard three-view drawing):
|
||
- Front view: lower-left area
|
||
- Top view: directly above front view
|
||
- Right side view: directly to right of front view
|
||
- Isometric: upper-right (optional)
|
||
|
||
WORKFLOW:
|
||
1. drawing_new() + set units with system_set_variable('INSUNITS', 4) # mm
|
||
2. layer_create() for all layers
|
||
3. Draw front view outlines on VISIBLE layer
|
||
4. Add hidden lines on HIDDEN layer
|
||
5. Add center lines on CENTER layer (use entity_create_line with CENTER linetype)
|
||
6. Add dimensions on DIMENSIONS layer
|
||
7. Add section hatch on HATCHING layer (ANSI31 pattern)
|
||
8. Add title block text on ANNOTATIONS layer
|
||
"""
|
||
|
||
|
||
@mcp.prompt(tags={"template", "utility"})
|
||
def prompt_quick_drawing(
|
||
description: str,
|
||
) -> str:
|
||
"""Generate step-by-step instructions for creating a drawing from a description."""
|
||
return f"""Create a CAD drawing based on this description: {description}
|
||
|
||
SYSTEMATIC APPROACH:
|
||
|
||
STEP 1 — PLANNING
|
||
- What entities are needed? (lines, circles, arcs, polylines, text)
|
||
- What layers should be used?
|
||
- What are the approximate dimensions?
|
||
- Is there any existing drawing to modify?
|
||
|
||
STEP 2 — SETUP
|
||
Use drawing_new() or drawing_open() first.
|
||
Create necessary layers with layer_create().
|
||
Set the current layer with layer_set_current().
|
||
|
||
STEP 3 — DRAWING
|
||
Create entities in logical order:
|
||
- Large shapes first (boundaries, major outlines)
|
||
- Details and features next
|
||
- Annotations and dimensions last
|
||
|
||
STEP 4 — VERIFY
|
||
Use analysis_entity_stats() to confirm what was created.
|
||
Use view_zoom_and_screenshot() to see the current state.
|
||
Use entity_list() to check specific entities.
|
||
|
||
STEP 5 — SAVE
|
||
Use drawing_save() or drawing_export_dxf() to save the result.
|
||
|
||
TIPS:
|
||
- Use transaction_begin() before complex operations
|
||
- All coordinates are in drawing units (mm by default)
|
||
- Angles are in degrees, counter-clockwise from X axis
|
||
- Entity handles are hex strings (e.g. '1A2B') — save them for later editing
|
||
"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||
|
||
|
||
def _validate_http_bind(host: str) -> None:
|
||
"""Refuse non-loopback HTTP bind unless explicitly opted in.
|
||
|
||
Without this guard, `--host 0.0.0.0` would expose 80+ tools (including
|
||
arbitrary file open/save and AutoCAD command execution) to the network
|
||
with no authentication — see the audit notes for the threat model.
|
||
"""
|
||
if host in _LOOPBACK_HOSTS:
|
||
return
|
||
if not config.settings.allow_remote_http:
|
||
raise SystemExit(
|
||
f"Refusing to bind HTTP on non-loopback host '{host}'. "
|
||
"Set ALLOW_REMOTE_HTTP=true and MCP_AUTH_TOKEN=<token> to opt in. "
|
||
"Without auth, any client on the network can run AutoCAD commands."
|
||
)
|
||
if not config.settings.mcp_auth_token:
|
||
raise SystemExit(
|
||
f"Refusing to bind HTTP on '{host}' without MCP_AUTH_TOKEN. "
|
||
"Set MCP_AUTH_TOKEN=<token> to enable bearer-token auth, or bind "
|
||
"to 127.0.0.1 for local-only access."
|
||
)
|
||
log.warning(
|
||
"⚠ Binding HTTP on non-loopback host '%s'. Auth token required for all "
|
||
"requests. Make sure your firewall and TLS termination are in order.",
|
||
host,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
transport = "stdio"
|
||
host = "127.0.0.1"
|
||
port = 8000
|
||
|
||
args = sys.argv[1:]
|
||
if "--transport" in args:
|
||
idx = args.index("--transport")
|
||
transport = args[idx + 1]
|
||
if "--port" in args:
|
||
idx = args.index("--port")
|
||
port = int(args[idx + 1])
|
||
if "--host" in args:
|
||
idx = args.index("--host")
|
||
host = args[idx + 1]
|
||
|
||
if transport == "stdio":
|
||
mcp.run()
|
||
else:
|
||
_validate_http_bind(host)
|
||
mcp.run(transport=transport, host=host, port=port)
|