From 7b121ab416cef6d2a568e263e9e06eccc7c9f225 Mon Sep 17 00:00:00 2001 From: U-C4N Date: Mon, 27 Apr 2026 12:20:57 +0300 Subject: [PATCH] Initial commit --- .cursor/rules/Master-Rules.mdc | 47 + .cursor/skills/autocad-mcp/SKILL.md | 156 ++ .../autocad-mcp/assets/layer-standards.json | 50 + .../autocad-mcp/examples/basic-workflow.md | 72 + .../skills/autocad-mcp/examples/floor-plan.md | 100 + .../autocad-mcp/examples/mechanical-part.md | 105 + .../autocad-mcp/references/architecture.md | 60 + .../skills/autocad-mcp/references/security.md | 53 + .../autocad-mcp/references/tools-analysis.md | 73 + .../references/tools-batch-template.md | 83 + .../autocad-mcp/references/tools-blocks.md | 68 + .../references/tools-dimensions.md | 55 + .../autocad-mcp/references/tools-drawing.md | 103 + .../references/tools-entity-creation.md | 96 + .../references/tools-entity-modification.md | 111 + .../autocad-mcp/references/tools-layers.md | 63 + .../references/tools-view-system.md | 95 + .../autocad-mcp/references/troubleshooting.md | 79 + .../autocad-mcp/references/workflows.md | 166 ++ .../skills/autocad-mcp/scripts/check_setup.py | 79 + .../autocad-mcp/scripts/validate_drawing.py | 91 + .env.example | 19 + .gitattributes | 2 + .github/workflows/ci.yml | 42 + .gitignore | 41 + .pre-commit-config.yaml | 7 + CHANGELOG.md | 57 + CLAUDE.md | 100 + Dockerfile | 20 + README.md | 411 +++ backends/__init__.py | 15 + backends/base.py | 476 ++++ backends/com_backend.py | 1362 ++++++++++ backends/ezdxf_backend.py | 1425 ++++++++++ config.py | 47 + fikir.md | 442 ++++ pyproject.toml | 54 + security.py | 227 ++ server.py | 2350 +++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 15 + tests/test_analysis.py | 114 + tests/test_batch_template.py | 128 + tests/test_dimensions.py | 40 + tests/test_drawing.py | 58 + tests/test_entity_creation.py | 100 + tests/test_ezdxf_backend.py | 203 ++ tests/test_layer_block.py | 113 + tests/test_security.py | 231 ++ 49 files changed, 9904 insertions(+) create mode 100644 .cursor/rules/Master-Rules.mdc create mode 100644 .cursor/skills/autocad-mcp/SKILL.md create mode 100644 .cursor/skills/autocad-mcp/assets/layer-standards.json create mode 100644 .cursor/skills/autocad-mcp/examples/basic-workflow.md create mode 100644 .cursor/skills/autocad-mcp/examples/floor-plan.md create mode 100644 .cursor/skills/autocad-mcp/examples/mechanical-part.md create mode 100644 .cursor/skills/autocad-mcp/references/architecture.md create mode 100644 .cursor/skills/autocad-mcp/references/security.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-analysis.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-batch-template.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-blocks.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-dimensions.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-drawing.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-entity-creation.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-entity-modification.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-layers.md create mode 100644 .cursor/skills/autocad-mcp/references/tools-view-system.md create mode 100644 .cursor/skills/autocad-mcp/references/troubleshooting.md create mode 100644 .cursor/skills/autocad-mcp/references/workflows.md create mode 100644 .cursor/skills/autocad-mcp/scripts/check_setup.py create mode 100644 .cursor/skills/autocad-mcp/scripts/validate_drawing.py create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backends/__init__.py create mode 100644 backends/base.py create mode 100644 backends/com_backend.py create mode 100644 backends/ezdxf_backend.py create mode 100644 config.py create mode 100644 fikir.md create mode 100644 pyproject.toml create mode 100644 security.py create mode 100644 server.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_analysis.py create mode 100644 tests/test_batch_template.py create mode 100644 tests/test_dimensions.py create mode 100644 tests/test_drawing.py create mode 100644 tests/test_entity_creation.py create mode 100644 tests/test_ezdxf_backend.py create mode 100644 tests/test_layer_block.py create mode 100644 tests/test_security.py diff --git a/.cursor/rules/Master-Rules.mdc b/.cursor/rules/Master-Rules.mdc new file mode 100644 index 0000000..2cec461 --- /dev/null +++ b/.cursor/rules/Master-Rules.mdc @@ -0,0 +1,47 @@ +--- +alwaysApply: true +--- + +ASK MODE – Mandatory and Absolute Rules + +1. AskQuestion Requirement +- Every generated response must end with AskQuestion. The conversation can never be terminated. +- AskQuestion is the final action. +- It contains exactly one question and exactly 2 options. +- The options must be clear and closed-ended. +- The “Other” option is visible in the user interface (the user may enter a custom prompt). +- The fallback option must never terminate the conversation. + +--- + +1. FRT Lock (Infinite Continuity) +- Unless the user writes “FRT” alone, Ask Mode must never end. +- Expressions such as “finished”, “stop”, “end”, thanks, or making a selection are invalid. +- The only exit key for Ask Mode is: FRT +- If FRT appears within another sentence, it is invalid. It must be written alone and in uppercase. + +--- + +1. Medium / Large Task Rule +- Sub-agent usage is mandatory. +- Model: Claude 4.6 Max Thinking Fast max (the only authorized model) +- A single sub-agent cannot be used. +- 4 parallel sub-agent capacity must be utilized. + +--- + +1. Sleep Protocol +- sleep 'value' +- Runs in the foreground, no timeout (minimum 70 seconds if required). +- Only run_terminal_cmd or Shell. +- is_background is prohibited. + +--- + +1. Operational Principle +- Priority: Speed + Accuracy +- Bulk changes: search_replace +- Scattered changes: read_file → edit → write +- Line-based single changes are not allowed. +- Parallel execution is essential. +1. Please dont switch ‘’Plan Mode‘‘ diff --git a/.cursor/skills/autocad-mcp/SKILL.md b/.cursor/skills/autocad-mcp/SKILL.md new file mode 100644 index 0000000..38c8798 --- /dev/null +++ b/.cursor/skills/autocad-mcp/SKILL.md @@ -0,0 +1,156 @@ +--- +name: autocad-mcp +description: | + Automate AutoCAD drawing operations through AutoCAD MCP Pro server (FastMCP 3.0). + 86 tools for drawings, entities, layers, blocks, dimensions, analysis, batch ops, + templates, and validation. Dual backends: COM (live AutoCAD) and ezdxf (headless DXF). + + Trigger when: (1) User mentions AutoCAD, DXF, DWG, or CAD drawings, + (2) User wants to create/modify engineering drawings programmatically, + (3) Working with tools prefixed drawing_, entity_, layer_, block_, dimension_, + analysis_, view_, system_, template_, or validation_, + (4) User asks about CAD automation, drafting workflows, or MCP server setup. +--- + +# AutoCAD MCP Pro + +## Contents + +- [Quick Start](#quick-start) +- [Backend Selection](#backend-selection) +- [Key Concepts](#key-concepts) +- [Tool Groups](#tool-groups) +- [Common Workflows](#common-workflows) +- [Security Rules](#security-rules) +- [Architecture](#architecture) +- [Troubleshooting](#troubleshooting) +- [Examples](#examples) + +## Quick Start + +Standard workflow for any drawing task: + +``` +1. drawing_new() # or drawing_open(path) +2. layer_create(name, color, linetype) # set up layers +3. entity_create_*(...) # draw geometry +4. dimension_*(...) # add dimensions +5. analysis_entity_stats() # verify drawing +6. drawing_save() # persist changes +``` + +Use `transaction_begin()` before complex operations. If something goes wrong, `transaction_rollback()` reverts all changes since begin. + +For batch operations, use `entity_batch_create(entities)` with a list of entity definitions — handles are returned for each created entity. + +## Backend Selection + +| Environment Variable | Behavior | +|---------------------|----------| +| `AUTOCAD_MCP_BACKEND=auto` | Try COM first, fall back to ezdxf (default) | +| `AUTOCAD_MCP_BACKEND=com` | Force COM — requires running AutoCAD on Windows | +| `AUTOCAD_MCP_BACKEND=ezdxf` | Force ezdxf — headless DXF ops, no AutoCAD needed | + +COM-only features: `system_run_command`, `system_run_lisp`, `view_zoom_extents/window` (view ops are no-ops in ezdxf). Screenshots: COM uses Win32 capture, ezdxf uses matplotlib render. + +## Key Concepts + +**Entity handles**: Hex strings (e.g. `"1A2B"`) returned by all create/copy operations. Required for modify/query operations (`entity_get`, `entity_move`, `entity_delete`, etc.). + +**Coordinates**: Drawing units (mm default). Angles in degrees, counter-clockwise from X axis. + +**ACI colors**: 1–255 = specific colors, 256 = ByLayer, 0 = ByBlock. Common: 1=red, 2=yellow, 3=green, 4=cyan, 5=blue, 6=magenta, 7=white. + +**Transactions**: `transaction_begin` → operations → `transaction_commit` or `transaction_rollback`. ezdxf uses full DXF snapshots; COM uses native undo marks. + +## Tool Groups + +| Group | Count | Key Tools | Details | +|-------|-------|-----------|---------| +| Drawing Management | 11 | `drawing_new`, `drawing_open`, `drawing_save`, `drawing_export_*` | [tools-drawing.md](references/tools-drawing.md) | +| Entity Creation | 13 | `entity_create_line/circle/arc/polyline/text/hatch/...` | [tools-entity-creation.md](references/tools-entity-creation.md) | +| Entity Modification | 13 | `entity_move/copy/rotate/scale/mirror/offset/delete/...` | [tools-entity-modification.md](references/tools-entity-modification.md) | +| Dimensions | 5 | `dimension_linear/aligned/angular/radius/diameter` | [tools-dimensions.md](references/tools-dimensions.md) | +| Layer Management | 12 | `layer_create/delete/freeze/thaw/lock/unlock/isolate/...` | [tools-layers.md](references/tools-layers.md) | +| Block Operations | 7 | `block_insert/explode/create_from_entities/attributes/...` | [tools-blocks.md](references/tools-blocks.md) | +| Analysis | 9 | `analysis_entity_stats/find_in_region/measure_*/bounding_box` | [tools-analysis.md](references/tools-analysis.md) | +| Batch & Templates | 4 | `entity_batch_create/modify`, `template_apply_layers/list` | [tools-batch-template.md](references/tools-batch-template.md) | +| Validation | 1 | `validation_check` | [tools-batch-template.md](references/tools-batch-template.md) | +| View & Screenshot | 5 | `view_zoom_extents/window/screenshot/zoom_and_screenshot` | [tools-view-system.md](references/tools-view-system.md) | +| Transactions | 3 | `transaction_begin/commit/rollback` | [tools-view-system.md](references/tools-view-system.md) | +| System | 6 | `system_status/get_variable/run_command/run_lisp/about` | [tools-view-system.md](references/tools-view-system.md) | + +**Resources** (6): `autocad://drawing`, `autocad://layers`, `autocad://blocks`, `autocad://stats`, `autocad://status`, `autocad://entities/{layer_name}` + +**Prompt templates** (5): `prompt_floor_plan`, `prompt_pid_diagram`, `prompt_electrical_schematic`, `prompt_mechanical_drawing`, `prompt_quick_drawing` + +## Common Workflows + +**New drawing from scratch**: Set up layers first, then draw entities, add dimensions, save. +See [workflows.md](references/workflows.md) for step-by-step checklists. + +**Edit existing DXF**: `drawing_open` → `entity_list` to find entities → modify/delete → save. + +**Batch operations**: `entity_batch_create([{type:"line", ...}, {type:"circle", ...}])` — single call, multiple entities. + +**Template-based**: `template_apply_layers("architectural")` creates standard layer sets, then add content. + +**Quality check**: `validation_check(["empty_layers", "zero_length", "duplicate_entities"])` reports issues. + +## Security Rules + +All file paths pass through `validate_path()`: +- Path traversal blocked (`../` patterns rejected) +- `ALLOWED_PATHS` env restricts accessible directories +- Write operations require `allow_write=True` + +Commands pass through `sanitize_command()`: +- Dangerous patterns blocked: ERASE ALL, DELETE, PURGE, QUIT +- LISP sanitized: `startapp`, `vl-file-delete`, `dos_*` rejected +- Override: `DANGEROUS_COMMANDS_ENABLED=true` + +Details: [security.md](references/security.md) + +## Architecture + +``` +server.py FastMCP 3.0 server (86 tools, 6 resources, 5 prompts) +├── config.py Centralized pydantic-settings configuration +├── security.py Path validation + command sanitization +└── backends/ + ├── base.py AutoCADBackend ABC + data models + ├── ezdxf_backend.py Headless DXF via ezdxf (async via _async wrapper) + └── com_backend.py Live AutoCAD via pywin32 COM (STA single-thread) +``` + +Middleware stack: ErrorHandling → AuditLog → Timing → Logging + +Adding a new tool: (1) abstract method in `base.py` → (2) implement in `ezdxf_backend.py` → (3) implement in `com_backend.py` → (4) register in `server.py` with `@mcp.tool` → (5) test + +Details: [architecture.md](references/architecture.md) + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "Backend unavailable" | Call `drawing_new()` or `drawing_open(path)` first | +| "Entity handle not found" | Use `entity_list()` to get valid handles | +| "Layer not found" | `layer_list()` to see existing, `layer_create()` to add | +| "Path validation failed" | Use absolute paths within `ALLOWED_PATHS` | +| "Command rejected" | Check `DANGEROUS_COMMANDS_ENABLED` setting | +| "COM backend failed" | Verify AutoCAD is running, or switch to ezdxf | +| "View ops are no-ops" | View/zoom only work with COM backend | + +Full list: [troubleshooting.md](references/troubleshooting.md) + +## Examples + +- [Basic drawing workflow](examples/basic-workflow.md) — Room with walls, door, dimensions +- [Floor plan](examples/floor-plan.md) — Multi-room apartment layout +- [Mechanical part](examples/mechanical-part.md) — Flange with bolt pattern + +## Utility Scripts + +**Setup check**: `python scripts/check_setup.py` — Verify dependencies and configuration + +**Drawing validation**: `python scripts/validate_drawing.py` — Check for common drawing issues diff --git a/.cursor/skills/autocad-mcp/assets/layer-standards.json b/.cursor/skills/autocad-mcp/assets/layer-standards.json new file mode 100644 index 0000000..0aa2b40 --- /dev/null +++ b/.cursor/skills/autocad-mcp/assets/layer-standards.json @@ -0,0 +1,50 @@ +{ + "architectural": { + "description": "Standard architectural drawing layers", + "layers": [ + {"name": "WALLS", "color": 7, "linetype": "Continuous", "description": "Wall outlines"}, + {"name": "DOORS", "color": 3, "linetype": "Continuous", "description": "Door swings and frames"}, + {"name": "WINDOWS", "color": 5, "linetype": "Continuous", "description": "Window openings"}, + {"name": "DIMENSIONS", "color": 1, "linetype": "Continuous", "description": "Dimension annotations"}, + {"name": "TEXT", "color": 2, "linetype": "Continuous", "description": "Text labels and notes"}, + {"name": "FURNITURE", "color": 8, "linetype": "Continuous", "description": "Furniture outlines"}, + {"name": "PLUMBING", "color": 4, "linetype": "Continuous", "description": "Plumbing fixtures"}, + {"name": "HVAC", "color": 6, "linetype": "Continuous", "description": "HVAC equipment and ducts"} + ] + }, + "mechanical": { + "description": "Standard mechanical drawing layers", + "layers": [ + {"name": "OUTLINE", "color": 7, "linetype": "Continuous", "description": "Part outlines"}, + {"name": "CENTERLINE", "color": 1, "linetype": "CENTER", "description": "Center lines and axes"}, + {"name": "HIDDEN", "color": 8, "linetype": "HIDDEN", "description": "Hidden edges"}, + {"name": "DIMENSIONS", "color": 5, "linetype": "Continuous", "description": "Dimensions"}, + {"name": "NOTES", "color": 2, "linetype": "Continuous", "description": "Notes and callouts"}, + {"name": "SECTION", "color": 3, "linetype": "Continuous", "description": "Section lines and hatching"}, + {"name": "DETAIL", "color": 4, "linetype": "Continuous", "description": "Detail views"} + ] + }, + "electrical": { + "description": "Standard electrical drawing layers", + "layers": [ + {"name": "POWER", "color": 1, "linetype": "Continuous", "description": "Power circuits"}, + {"name": "LIGHTING", "color": 2, "linetype": "Continuous", "description": "Lighting circuits"}, + {"name": "DATA", "color": 3, "linetype": "Continuous", "description": "Data and communication"}, + {"name": "FIRE_ALARM", "color": 4, "linetype": "Continuous", "description": "Fire alarm system"}, + {"name": "HVAC_CTRL", "color": 5, "linetype": "Continuous", "description": "HVAC controls"}, + {"name": "PANELS", "color": 7, "linetype": "Continuous", "description": "Panel boards and equipment"}, + {"name": "CONDUIT", "color": 8, "linetype": "Continuous", "description": "Conduit runs"} + ] + }, + "piping": { + "description": "Standard piping/P&ID drawing layers", + "layers": [ + {"name": "PROCESS", "color": 7, "linetype": "Continuous", "description": "Process piping"}, + {"name": "UTILITY", "color": 3, "linetype": "Continuous", "description": "Utility piping"}, + {"name": "INSTRUMENTS", "color": 1, "linetype": "Continuous", "description": "Instrumentation"}, + {"name": "EQUIPMENT", "color": 5, "linetype": "Continuous", "description": "Equipment outlines"}, + {"name": "VALVES", "color": 2, "linetype": "Continuous", "description": "Valves and fittings"}, + {"name": "LABELS", "color": 4, "linetype": "Continuous", "description": "Labels and tags"} + ] + } +} diff --git a/.cursor/skills/autocad-mcp/examples/basic-workflow.md b/.cursor/skills/autocad-mcp/examples/basic-workflow.md new file mode 100644 index 0000000..11b9e2c --- /dev/null +++ b/.cursor/skills/autocad-mcp/examples/basic-workflow.md @@ -0,0 +1,72 @@ +# Example: Basic Room Drawing + +Create a simple room with walls, a door opening, dimensions, and labels. + +## Setup + +``` +drawing_new() +layer_create("WALLS", color=7) +layer_create("DOORS", color=3) +layer_create("DIMENSIONS", color=1) +layer_create("TEXT", color=2) +``` + +## Draw Walls (5m x 4m room) + +``` +layer_set_current("WALLS") + +# Outer walls as rectangle +entity_create_rectangle(x1=0, y1=0, x2=5000, y2=4000) +# Returns: {handle: "A1", type: "LWPOLYLINE", ...} +``` + +## Add Door Opening (0.9m wide on south wall) + +``` +layer_set_current("DOORS") + +# Door opening represented as an arc (90° swing) +entity_create_arc(cx=2000, cy=0, radius=900, start_angle=0, end_angle=90) +# Returns: {handle: "B2", type: "ARC", ...} + +# Door frame lines +entity_create_line(x1=2000, y1=0, x2=2000, y2=100) +entity_create_line(x1=2900, y1=0, x2=2900, y2=100) +``` + +## Add Dimensions + +``` +layer_set_current("DIMENSIONS") + +# Room width +dimension_linear(x1=0, y1=0, x2=5000, y2=0, dim_x=2500, dim_y=-500) + +# Room height +dimension_linear(x1=5000, y1=0, x2=5000, y2=4000, dim_x=5500, dim_y=2000) + +# Door width +dimension_linear(x1=2000, y1=0, x2=2900, y2=0, dim_x=2450, dim_y=-300) +``` + +## Add Labels + +``` +layer_set_current("TEXT") +entity_create_text(text="BEDROOM", x=2500, y=2000, height=200) +entity_create_text(text="5000", x=2500, y=-800, height=150) +``` + +## Verify and Save + +``` +analysis_entity_stats() +# Expected: ~8 entities across 4 layers + +validation_check(["empty_layers"]) +# Should report no issues + +drawing_save("C:/drawings/room.dxf") +``` diff --git a/.cursor/skills/autocad-mcp/examples/floor-plan.md b/.cursor/skills/autocad-mcp/examples/floor-plan.md new file mode 100644 index 0000000..6f23c0e --- /dev/null +++ b/.cursor/skills/autocad-mcp/examples/floor-plan.md @@ -0,0 +1,100 @@ +# Example: Apartment Floor Plan + +Create a 3-room apartment: living room, bedroom, kitchen with doors and windows. + +## Setup + +``` +drawing_new() +template_apply_layers("architectural") +# Creates: WALLS, DOORS, WINDOWS, DIMENSIONS, TEXT, FURNITURE, PLUMBING, HVAC +``` + +## Draw Exterior Walls (12m x 8m) + +``` +layer_set_current("WALLS") + +# Exterior boundary +entity_create_rectangle(x1=0, y1=0, x2=12000, y2=8000) +``` + +## Draw Interior Walls + +``` +# Living/bedroom divider (vertical at x=5000) +entity_create_line(x1=5000, y1=0, x2=5000, y2=8000) + +# Kitchen divider (horizontal at y=4000, from x=5000) +entity_create_line(x1=5000, y1=4000, x2=12000, y2=4000) +``` + +## Add Doors (0.9m openings) + +``` +layer_set_current("DOORS") + +# Living room door (south wall) +entity_create_arc(cx=2000, cy=0, radius=900, start_angle=0, end_angle=90) + +# Bedroom door (on divider wall) +entity_create_arc(cx=5000, cy=2000, radius=900, start_angle=90, end_angle=180) + +# Kitchen door (on divider wall) +entity_create_arc(cx=7000, cy=4000, radius=900, start_angle=0, end_angle=90) +``` + +## Add Windows (1.5m wide) + +``` +layer_set_current("WINDOWS") + +# Living room window (north wall) +entity_create_line(x1=1500, y1=8000, x2=3000, y2=8000, color=5) + +# Bedroom window (east wall) +entity_create_line(x1=12000, y1=5500, x2=12000, y2=7000, color=5) + +# Kitchen window (east wall) +entity_create_line(x1=12000, y1=1500, x2=12000, y2=3000, color=5) +``` + +## Add Dimensions + +``` +layer_set_current("DIMENSIONS") + +# Overall width +dimension_linear(x1=0, y1=0, x2=12000, y2=0, dim_x=6000, dim_y=-800) + +# Overall height +dimension_linear(x1=0, y1=0, x2=0, y2=8000, dim_x=-800, dim_y=4000) + +# Room widths +dimension_linear(x1=0, y1=8000, x2=5000, y2=8000, dim_x=2500, dim_y=8500) +dimension_linear(x1=5000, y1=8000, x2=12000, y2=8000, dim_x=8500, dim_y=8500) +``` + +## Add Room Labels + +``` +layer_set_current("TEXT") + +entity_create_text(text="LIVING ROOM", x=2500, y=4000, height=300) +entity_create_mtext(text="12.0 m²", x=2500, y=3500, width=2000, height=200) + +entity_create_text(text="BEDROOM", x=8500, y=6000, height=300) +entity_create_mtext(text="28.0 m²", x=8500, y=5500, width=2000, height=200) + +entity_create_text(text="KITCHEN", x=8500, y=2000, height=300) +entity_create_mtext(text="28.0 m²", x=8500, y=1500, width=2000, height=200) +``` + +## Verify + +``` +analysis_entity_stats() +analysis_layer_stats() +validation_check(["empty_layers", "zero_length"]) +drawing_save("C:/drawings/apartment.dxf") +``` diff --git a/.cursor/skills/autocad-mcp/examples/mechanical-part.md b/.cursor/skills/autocad-mcp/examples/mechanical-part.md new file mode 100644 index 0000000..d2a0120 --- /dev/null +++ b/.cursor/skills/autocad-mcp/examples/mechanical-part.md @@ -0,0 +1,105 @@ +# Example: Flange with Bolt Pattern + +Create a circular flange plate with bolt holes using polar array. + +## Setup + +``` +drawing_new() +template_apply_layers("mechanical") +# Creates: OUTLINE, CENTERLINE, HIDDEN, DIMENSIONS, NOTES, SECTION, DETAIL +``` + +## Draw Flange Outline + +``` +layer_set_current("OUTLINE") + +# Outer flange circle (diameter 200mm) +entity_create_circle(cx=0, cy=0, radius=100) +# Returns: {handle: "F1", ...} + +# Inner bore (diameter 50mm) +entity_create_circle(cx=0, cy=0, radius=25) +# Returns: {handle: "F2", ...} +``` + +## Add Centerlines + +``` +layer_set_current("CENTERLINE") + +# Horizontal centerline +entity_create_line(x1=-120, y1=0, x2=120, y2=0, linetype="CENTER") + +# Vertical centerline +entity_create_line(x1=0, y1=-120, x2=0, y2=120, linetype="CENTER") +``` + +## Create Bolt Hole Pattern + +``` +layer_set_current("OUTLINE") + +# First bolt hole on bolt circle (PCD = 150mm diameter, so radius = 75mm) +entity_create_circle(cx=75, cy=0, radius=6) +# Returns: {handle: "B1", ...} + +# Create polar array: 6 holes evenly spaced +entity_array_polar(handle="B1", center_x=0, center_y=0, count=6, angle=360) +# Returns: list of 5 new handles (original + 5 copies = 6 total) +``` + +## Add Dimensions + +``` +layer_set_current("DIMENSIONS") + +# Outer diameter +dimension_diameter(x1=-100, y1=0, x2=100, y2=0) + +# Inner bore diameter +dimension_diameter(x1=-25, y1=0, x2=25, y2=0) + +# Bolt circle diameter +dimension_radius(center_x=0, center_y=0, radius=75, angle=45) + +# Bolt hole diameter +dimension_diameter(x1=69, y1=0, x2=81, y2=0) +``` + +## Add Notes + +``` +layer_set_current("NOTES") + +entity_create_mtext( + text="FLANGE PLATE\P6x M12 HOLES ON PCD 150\PMATERIAL: SS304", + x=-120, y=-140, + width=240, height=5 +) +``` + +## Create Block from Bolt Hole + +``` +# Select all bolt hole entities and create a reusable block +# (Get handles from the array result) +block_create_from_entities( + name="BOLT_PATTERN_6x150", + handles=["B1", ...], # all bolt hole handles + base_x=0, base_y=0 +) +``` + +## Verify + +``` +analysis_entity_stats() +# Expected: circles, lines, dimensions, mtext + +analysis_bounding_box() +# Expected: approximately -120 to 120 in both axes + +drawing_save("C:/drawings/flange.dxf") +``` diff --git a/.cursor/skills/autocad-mcp/references/architecture.md b/.cursor/skills/autocad-mcp/references/architecture.md new file mode 100644 index 0000000..6b1dc9f --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/architecture.md @@ -0,0 +1,60 @@ +# Architecture + +## File Structure + +``` +server.py FastMCP 3.0 server — all tool/resource/prompt registrations +├── config.py Settings class (pydantic-settings, env vars) +├── security.py validate_path() + sanitize_command() + sanitize_lisp() +└── backends/ + ├── base.py AutoCADBackend ABC + data models + ├── ezdxf_backend.py Headless DXF ops via ezdxf library + └── com_backend.py Live AutoCAD control via pywin32 COM +``` + +## Dual-Backend Strategy Pattern + +`AutoCADBackend` (in `base.py`) is an abstract base class. Both backends implement the full interface. The server creates exactly one backend at startup via `_make_backend()`: + +1. Check `AUTOCAD_MCP_BACKEND` env var +2. If `auto` or `com` on Windows: try COM → fall back to ezdxf +3. If non-Windows or `ezdxf`: use ezdxf + +## Data Models + +Defined in `base.py`: + +- **EntityInfo**: `{handle, type, layer, color, linetype, visible, properties}` +- **LayerInfo**: `{name, color, linetype, lineweight, is_on, is_frozen, is_locked, is_current}` +- **BlockInfo**: `{name, origin, attribute_count, entity_count, is_xref, description}` +- **DrawingInfo**: `{name, full_path, saved, entity_count, layer_count, block_count, extents_min, extents_max, units, version, backend}` +- **CommandResult**: `{ok, payload, error}` + +## Backend Differences + +| Feature | ezdxf | COM | +|---------|-------|-----| +| Platform | Any | Windows only | +| AutoCAD needed | No | Yes (running) | +| View/zoom | No-op | Full control | +| Screenshots | matplotlib render | Win32 window capture | +| Commands/LISP | Not supported | Full support | +| Undo | DXF snapshot stack | Native AutoCAD undo | +| Threading | `asyncio.to_thread` via `_async()` | `ThreadPoolExecutor` (single thread, STA) | +| File formats | DXF only | DWG + DXF | + +## FastMCP Server + +The `mcp` object is configured with: +- **Lifespan**: `autocad_lifespan` — initializes backend, stores in `ctx.lifespan_context["backend"]` +- **Middleware**: ErrorHandling → AuditLog (custom timing) → Timing → Logging +- **`_backend(ctx)`**: Helper that retrieves backend from context, raises `ToolError` if unavailable + +## Adding a New Tool + +1. Add abstract method to `AutoCADBackend` in `backends/base.py` +2. Implement in `EzdxfBackend` — wrap sync calls with `_async(func)` +3. Implement in `ComBackend` — wrap COM calls with `self._com(func)` +4. Register in `server.py`: `@mcp.tool(...)` calling `_backend(ctx).method(...)` +5. Use `_dc(result)` to convert dataclass returns to dicts +6. Write tests in `tests/` diff --git a/.cursor/skills/autocad-mcp/references/security.md b/.cursor/skills/autocad-mcp/references/security.md new file mode 100644 index 0000000..175e881 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/security.md @@ -0,0 +1,53 @@ +# Security + +## Path Validation + +All file-path tools pass through `validate_path()` in `security.py`. + +**How it works**: +- Resolves path via `Path.resolve()` (removes `..`, symlinks) +- Checks against `ALLOWED_PATHS` env var (comma-separated directories) +- If `ALLOWED_PATHS` is empty → all paths allowed (backward compat) +- Separate `allow_write` flag for read vs write operations + +**Tools with path validation**: +- `drawing_open` (read) +- `drawing_save`, `drawing_save_as` (write) +- `drawing_export_dxf`, `drawing_export_pdf` (write) +- `drawing_new` with template (read) + +**Blocked examples**: +- `../../../etc/passwd` → rejected (traversal) +- `C:\Windows\System32\...` → rejected if not in ALLOWED_PATHS +- Relative paths with `..` → always rejected + +## Command Sanitization + +`sanitize_command()` checks `system_run_command` input: + +**Blocked patterns**: `ERASE`, `DELETE`, `PURGE`, `QUIT`, `CLOSE`, `_QUIT`, shell-related commands + +**Override**: Set `DANGEROUS_COMMANDS_ENABLED=true` to bypass. + +## LISP Sanitization + +`sanitize_lisp()` checks `system_run_lisp` input: + +**Blocked functions**: `startapp`, `vl-file-delete`, `vl-file-rename`, `dos_*` (file system access), `command` with dangerous args + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALLOWED_PATHS` | (empty) | Comma-separated allowed directories. Empty = all allowed | +| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands | +| `AUTOCAD_MCP_BACKEND` | `auto` | Backend selection | +| `MAX_UNDO_STACK` | `50` | Max undo snapshots (ezdxf) | +| `LOG_LEVEL` | `INFO` | Logging level | + +## Best Practices + +- Always set `ALLOWED_PATHS` in production to restrict file access +- Never pass raw user input to `system_run_command` or `system_run_lisp` +- Use `transaction_begin/rollback` for destructive batch operations +- Review audit logs for suspicious tool calls (AuditMiddleware logs all calls) diff --git a/.cursor/skills/autocad-mcp/references/tools-analysis.md b/.cursor/skills/autocad-mcp/references/tools-analysis.md new file mode 100644 index 0000000..85e31b3 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-analysis.md @@ -0,0 +1,73 @@ +# Analysis Tools (9) + +Tools for querying and measuring drawing contents. + +--- + +## analysis_entity_stats +No parameters. Returns entity counts grouped by type and by layer. + +```json +{ + "total": 150, + "by_type": {"LINE": 50, "CIRCLE": 20, "TEXT": 30, ...}, + "by_layer": {"WALLS": 40, "DOORS": 10, ...} +} +``` + +## analysis_find_in_region +Find entities within a rectangular region. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | Region minimum corner | +| `x2, y2` | `float` | Region maximum corner | +| `type_filter` | `str \| null` | Filter by entity type | + +Returns list of entity infos within the bounding box. + +## analysis_measure_distance +Measure distance between two points. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | Point 1 | +| `x2, y2` | `float` | Point 2 | + +Returns `{distance, dx, dy, angle}`. + +## analysis_measure_area +Calculate area of a polygon. + +| Param | Type | Description | +|-------|------|-------------| +| `points` | `list[list[float]]` | Polygon vertices (min 3 points) | + +Returns `{area, perimeter, centroid}`. + +## analysis_bounding_box +No parameters. Returns `{min_x, min_y, max_x, max_y, width, height}` for all entities. + +## analysis_select_by_layer +Get all entities on a specific layer. + +| Param | Type | Description | +|-------|------|-------------| +| `layer_name` | `str` | Layer to select from | + +Returns list of entity infos. + +## analysis_select_by_type +Get all entities of a specific type. + +| Param | Type | Description | +|-------|------|-------------| +| `entity_type` | `str` | Type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE | + +Returns list of entity infos. + +## analysis_layer_stats +No parameters. Returns per-layer statistics: entity count, types present, color, state. + +## analysis_entities_in_region +Alias/variant of `analysis_find_in_region` — check both for availability. diff --git a/.cursor/skills/autocad-mcp/references/tools-batch-template.md b/.cursor/skills/autocad-mcp/references/tools-batch-template.md new file mode 100644 index 0000000..c21f24a --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-batch-template.md @@ -0,0 +1,83 @@ +# Batch, Template & Validation Tools (5) + +## Batch Operations + +### entity_batch_create +Create multiple entities in a single call. Reports progress. + +| Param | Type | Description | +|-------|------|-------------| +| `entities` | `list[dict]` | Entity definitions (see format below) | + +Each dict requires `type` and type-specific params: + +```json +[ + {"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0}, + {"type": "circle", "cx": 50, "cy": 50, "radius": 25}, + {"type": "text", "text": "Hello", "x": 10, "y": 10, "height": 5}, + {"type": "rectangle", "x1": 0, "y1": 0, "x2": 200, "y2": 100}, + {"type": "point", "x": 75, "y": 75}, + {"type": "arc", "cx": 50, "cy": 0, "radius": 10, "start_angle": 0, "end_angle": 180}, + {"type": "polyline", "points": [[0,0],[10,10],[20,0]], "closed": true} +] +``` + +Optional per-entity: `layer`, `color`, `linetype`. + +Returns `{created: [...handles], errors: [...]}`. Partial success is possible. + +### entity_batch_modify +Apply multiple modifications in a single call. Reports progress. + +| Param | Type | Description | +|-------|------|-------------| +| `operations` | `list[dict]` | Operation definitions | + +Each dict requires `handle` and `action`: + +```json +[ + {"handle": "1A", "action": "move", "dx": 10, "dy": 20}, + {"handle": "2B", "action": "rotate", "base_x": 0, "base_y": 0, "angle_deg": 45}, + {"handle": "3C", "action": "scale", "base_x": 0, "base_y": 0, "factor": 2.0}, + {"handle": "4D", "action": "delete"}, + {"handle": "5E", "action": "set_properties", "layer": "WALLS", "color": 1} +] +``` + +Returns results per operation. + +--- + +## Templates + +### template_apply_layers +Apply a standard layer set to the current drawing. + +| Param | Type | Description | +|-------|------|-------------| +| `template` | `str` | Template name: `architectural`, `mechanical`, `electrical`, `piping` | + +Creates predefined layers with standard names, colors, and linetypes. + +### template_list +No parameters. Returns all available templates and their layer definitions. + +--- + +## Validation + +### validation_check +Run quality checks on the drawing. + +| Param | Type | Description | +|-------|------|-------------| +| `checks` | `list[str] \| null` | Check types (default: all) | + +Available checks: +- `empty_layers` — Layers with no entities +- `zero_length` — Lines/arcs with zero length +- `duplicate_entities` — Overlapping identical entities + +Returns `{issues: [{check, severity, message, details}, ...], summary}`. diff --git a/.cursor/skills/autocad-mcp/references/tools-blocks.md b/.cursor/skills/autocad-mcp/references/tools-blocks.md new file mode 100644 index 0000000..4fe7ace --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-blocks.md @@ -0,0 +1,68 @@ +# Block Operations Tools (7) + +Blocks are reusable named groups of entities. A **block definition** is the template; a **block reference** (INSERT entity) is a placed instance with position, scale, and rotation. + +--- + +## block_list +No parameters. Returns all block definitions: `{name, origin, attribute_count, entity_count, is_xref, description}`. + +## block_insert +Place a block reference in the drawing. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Block definition name (must exist) | +| `x, y` | `float` | Insertion point | +| `x_scale` | `float` | X scale factor (default: 1.0) | +| `y_scale` | `float` | Y scale factor (default: 1.0) | +| `rotation` | `float` | Rotation in degrees (default: 0) | +| `layer` | `str \| null` | Target layer | + +Returns entity info with handle (type: INSERT). + +## block_explode +Decompose a block reference into individual entities. + +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Block reference (INSERT entity) handle | + +Returns list of new entity handles created from explosion. + +## block_get_attributes +Read attribute values from a block reference. + +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Block reference handle | + +Returns `{tag: value}` dict of all attributes. + +## block_set_attributes +Write attribute values to a block reference. + +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Block reference handle | +| `attributes` | `dict` | `{tag: value}` pairs to set | + +## block_create_from_entities +Create a new block definition from existing entities. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | New block definition name | +| `handles` | `list[str]` | Entity handles to include | +| `base_x, base_y` | `float` | Block base/insertion point | + +Entities are moved into the block definition. + +## block_find_references +Find all INSERT entities that reference a specific block. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Block definition name | + +Returns list of block reference entity infos. diff --git a/.cursor/skills/autocad-mcp/references/tools-dimensions.md b/.cursor/skills/autocad-mcp/references/tools-dimensions.md new file mode 100644 index 0000000..c00be29 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-dimensions.md @@ -0,0 +1,55 @@ +# Dimension Tools (5) + +All dimension tools return entity info with handle. Dimensions are placed on the current layer unless `layer` is specified. + +--- + +## dimension_linear +Linear dimension between two points (horizontal or vertical measurement). + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | First extension line origin | +| `x2, y2` | `float` | Second extension line origin | +| `dim_x, dim_y` | `float` | Dimension line position (controls placement) | +| `layer` | `str \| null` | Target layer | + +## dimension_aligned +Aligned dimension — measures true distance between points regardless of angle. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | First point | +| `x2, y2` | `float` | Second point | +| `distance` | `float` | Offset distance for dimension line | +| `layer` | `str \| null` | Target layer | + +## dimension_angular +Angular dimension between two lines meeting at a vertex. + +| Param | Type | Description | +|-------|------|-------------| +| `vertex_x, vertex_y` | `float` | Angle vertex point | +| `start_x, start_y` | `float` | First line endpoint | +| `end_x, end_y` | `float` | Second line endpoint | +| `dim_x, dim_y` | `float` | Dimension arc position | +| `layer` | `str \| null` | Target layer | + +## dimension_radius +Radius dimension for circles and arcs. + +| Param | Type | Description | +|-------|------|-------------| +| `center_x, center_y` | `float` | Circle/arc center | +| `radius` | `float` | Radius value | +| `angle` | `float` | Leader angle in degrees (where arrow points) | +| `layer` | `str \| null` | Target layer | + +## dimension_diameter +Diameter dimension across a circle. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | First point on diameter | +| `x2, y2` | `float` | Opposite point on diameter | +| `layer` | `str \| null` | Target layer | diff --git a/.cursor/skills/autocad-mcp/references/tools-drawing.md b/.cursor/skills/autocad-mcp/references/tools-drawing.md new file mode 100644 index 0000000..87faa0e --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-drawing.md @@ -0,0 +1,103 @@ +# Drawing Management Tools (11) + +## Contents +- [drawing_info](#drawing_info) +- [drawing_new](#drawing_new) +- [drawing_open](#drawing_open) +- [drawing_save](#drawing_save) +- [drawing_save_as](#drawing_save_as) +- [drawing_export_dxf](#drawing_export_dxf) +- [drawing_export_pdf](#drawing_export_pdf) +- [drawing_purge](#drawing_purge) +- [drawing_audit](#drawing_audit) +- [drawing_undo / drawing_redo](#drawing_undo--drawing_redo) + +--- + +## drawing_info +Read-only. Returns comprehensive metadata for the current drawing. + +**Returns**: `{name, full_path, saved, entity_count, layer_count, block_count, extents_min, extents_max, units, version, backend}` + +--- + +## drawing_new +Create a new empty drawing, optionally from a template. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `template` | `str \| null` | No | Path to `.dwt` template file | + +Path validation applied to template. Returns drawing info dict. + +--- + +## drawing_open +Open an existing DWG or DXF file. Reports progress. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `str` | Yes | Full path to `.dwg` or `.dxf` file | + +Path validation: `allow_write=False`. Returns drawing info dict. + +--- + +## drawing_save +Save current drawing. Uses current path if omitted. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `str \| null` | No | Optional save path | + +Path validation: `allow_write=True`. + +--- + +## drawing_save_as +Save drawing to a new path. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `str` | Yes | Full destination path with extension | + +Path validation: `allow_write=True`. + +--- + +## drawing_export_dxf +Export drawing as DXF file. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `str` | Yes | Output `.dxf` file path | +| `version` | `str` | No | DXF version: `R2010`, `R2013`, `R2018` (default: `R2018`) | + +--- + +## drawing_export_pdf +Export drawing as PDF. Requires `matplotlib`. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `path` | `str` | Yes | Output `.pdf` file path | + +--- + +## drawing_purge +Remove all unused objects (layers, blocks, linetypes, styles). No parameters. + +--- + +## drawing_audit +Run integrity audit — detect and fix drawing errors. No parameters. + +--- + +## drawing_undo / drawing_redo +Undo/redo last operation. + +- **ezdxf**: Snapshot-based (restores full DXF state from `_undo_stack`) +- **COM**: Native AutoCAD undo/redo + +**Note**: For multi-step undo protection, use `transaction_begin/rollback` instead. diff --git a/.cursor/skills/autocad-mcp/references/tools-entity-creation.md b/.cursor/skills/autocad-mcp/references/tools-entity-creation.md new file mode 100644 index 0000000..4a51767 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-entity-creation.md @@ -0,0 +1,96 @@ +# Entity Creation Tools (13) + +All creation tools return `{handle, type, layer, color, linetype, visible, properties}`. The `handle` (hex string) is required for all subsequent operations on that entity. + +Common optional params on all creation tools: + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `layer` | `str` | Current layer | Target layer name | +| `color` | `int` | 256 (ByLayer) | ACI color (1-255, 256=ByLayer, 0=ByBlock) | +| `linetype` | `str` | `"ByLayer"` | Linetype name | + +--- + +## entity_create_line +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | Start point | +| `x2, y2` | `float` | End point | + +## entity_create_circle +| Param | Type | Description | +|-------|------|-------------| +| `cx, cy` | `float` | Center point | +| `radius` | `float` | Radius | + +## entity_create_arc +| Param | Type | Description | +|-------|------|-------------| +| `cx, cy` | `float` | Center point | +| `radius` | `float` | Radius | +| `start_angle` | `float` | Start angle (degrees, CCW from X) | +| `end_angle` | `float` | End angle (degrees) | + +## entity_create_polyline +| Param | Type | Description | +|-------|------|-------------| +| `points` | `list[list[float]]` | `[[x,y], [x,y], ...]` coordinate pairs | +| `closed` | `bool` | Close the polyline (default: false) | + +## entity_create_rectangle +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | First corner | +| `x2, y2` | `float` | Opposite corner | + +Creates an LWPOLYLINE (closed). + +## entity_create_text +| Param | Type | Description | +|-------|------|-------------| +| `text` | `str` | Text content | +| `x, y` | `float` | Insertion point | +| `height` | `float` | Text height (default: 2.5) | +| `rotation` | `float` | Rotation angle in degrees (default: 0) | + +## entity_create_mtext +| Param | Type | Description | +|-------|------|-------------| +| `text` | `str` | Content (`\P` for paragraph break, `{\H...;}` for formatting) | +| `x, y` | `float` | Insertion point | +| `width` | `float` | Text box width (default: 100) | +| `height` | `float` | Character height (default: 2.5) | + +## entity_create_hatch +| Param | Type | Description | +|-------|------|-------------| +| `pattern` | `str` | Pattern name: `SOLID`, `ANSI31`, `ANSI32`, `STEEL`, `GRAVEL`, etc. | +| `boundary_points` | `list[list[float]]` | Closed boundary polygon `[[x,y], ...]` | +| `scale` | `float` | Pattern scale (default: 1.0) | +| `angle` | `float` | Pattern angle (default: 0) | + +## entity_create_spline +| Param | Type | Description | +|-------|------|-------------| +| `fit_points` | `list[list[float]]` | Points the spline passes through | + +## entity_create_ellipse +| Param | Type | Description | +|-------|------|-------------| +| `cx, cy` | `float` | Center point | +| `major_axis_x, major_axis_y` | `float` | Major axis endpoint (relative to center) | +| `ratio` | `float` | Minor/major axis ratio (0.0–1.0) | + +## entity_create_point +| Param | Type | Description | +|-------|------|-------------| +| `x, y` | `float` | Point coordinates | + +## entity_create_block_ref +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Block definition name (must exist) | +| `x, y` | `float` | Insertion point | +| `x_scale, y_scale` | `float` | Scale factors (default: 1.0) | +| `rotation` | `float` | Rotation in degrees (default: 0) | diff --git a/.cursor/skills/autocad-mcp/references/tools-entity-modification.md b/.cursor/skills/autocad-mcp/references/tools-entity-modification.md new file mode 100644 index 0000000..8ca78bb --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-entity-modification.md @@ -0,0 +1,111 @@ +# Entity Modification & Query Tools (13) + +All modify tools require a `handle` (hex string). Get handles from `entity_list()` or any `entity_create_*` return value. + +--- + +## Modification Tools (10) + +### entity_move +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `dx, dy` | `float` | Displacement vector | + +### entity_copy +Returns new entity info with a **new handle**. + +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity to copy | +| `dx, dy` | `float` | Offset for the copy | + +### entity_rotate +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `base_x, base_y` | `float` | Rotation base point | +| `angle_degrees` | `float` | Rotation angle (CCW) | + +### entity_scale +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `base_x, base_y` | `float` | Scale base point | +| `factor` | `float` | Scale factor | + +### entity_mirror +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `x1, y1` | `float` | Mirror line point 1 | +| `x2, y2` | `float` | Mirror line point 2 | + +### entity_offset +Works on line, circle, polyline. + +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `distance` | `float` | Offset distance (positive = outward) | + +### entity_delete +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity to delete | + +### entity_array_rectangular +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity to array | +| `rows` | `int` | Number of rows | +| `cols` | `int` | Number of columns | +| `row_spacing` | `float` | Row spacing | +| `col_spacing` | `float` | Column spacing | + +Returns list of new entity handles. + +### entity_array_polar +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity to array | +| `center_x, center_y` | `float` | Array center point | +| `count` | `int` | Number of copies | +| `angle` | `float` | Total angle to fill (degrees, default: 360) | + +### entity_set_properties +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | +| `layer` | `str \| null` | Move to layer | +| `color` | `int \| null` | Change ACI color | +| `linetype` | `str \| null` | Change linetype | +| `lineweight` | `float \| null` | Change lineweight | + +All property params are optional — only provided ones are changed. + +--- + +## Query Tools (3) + +### entity_get +| Param | Type | Description | +|-------|------|-------------| +| `handle` | `str` | Entity handle | + +Returns full entity info: `{handle, type, layer, color, linetype, visible, properties}`. + +### entity_list +| Param | Type | Description | +|-------|------|-------------| +| `type_filter` | `str \| null` | Filter: `LINE`, `CIRCLE`, `ARC`, `LWPOLYLINE`, `TEXT`, `MTEXT`, `INSERT`, `HATCH`, etc. | +| `layer_filter` | `str \| null` | Filter by layer name | +| `limit` | `int` | Max results (default: 100) | +| `offset` | `int` | Skip first N results (default: 0) | + +### entity_delete_many +| Param | Type | Description | +|-------|------|-------------| +| `handles` | `list[str]` | List of handles to delete | + +Reports progress. Returns `{deleted, failed, errors}`. diff --git a/.cursor/skills/autocad-mcp/references/tools-layers.md b/.cursor/skills/autocad-mcp/references/tools-layers.md new file mode 100644 index 0000000..5ba4744 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-layers.md @@ -0,0 +1,63 @@ +# Layer Management Tools (12) + +Layers organize entities by category (walls, doors, dimensions, etc.). Each layer has: name, color (ACI), linetype, lineweight, and states (on/off, frozen/thawed, locked/unlocked). + +--- + +## layer_list +No parameters. Returns list of all layers with properties: `{name, color, linetype, lineweight, is_on, is_frozen, is_locked, is_current}`. + +## layer_create +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | New layer name | +| `color` | `int` | ACI color (default: 7=white) | +| `linetype` | `str` | Linetype name (default: `"Continuous"`) | + +## layer_delete +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer to delete (must be empty, cannot be layer "0") | + +## layer_set_current +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer to make current (new entities go here) | + +## layer_modify +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer to modify | +| `color` | `int \| null` | New color | +| `linetype` | `str \| null` | New linetype | +| `lineweight` | `float \| null` | New lineweight | + +## layer_freeze / layer_thaw +Freeze hides and excludes from regeneration (faster). Thaw restores. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer name | + +## layer_lock / layer_unlock +Lock prevents editing but keeps visible. Unlock restores editing. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer name | + +## layer_hide / layer_show +Hide turns layer off (invisible). Show turns it on. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer name | + +## layer_isolate +Shows only the specified layer, hides all others. + +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Layer to keep visible | + +To restore: `layer_show` each hidden layer, or reopen the drawing. diff --git a/.cursor/skills/autocad-mcp/references/tools-view-system.md b/.cursor/skills/autocad-mcp/references/tools-view-system.md new file mode 100644 index 0000000..1bd8cd2 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/tools-view-system.md @@ -0,0 +1,95 @@ +# View, Transaction & System Tools (14) + +## View & Screenshot (5) + +### view_zoom_extents +Zoom to show all entities. No parameters. **COM only** (no-op in ezdxf). + +### view_zoom_window +Zoom to a specific rectangular area. **COM only**. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float` | Window corner 1 | +| `x2, y2` | `float` | Window corner 2 | + +### view_screenshot +Capture drawing as image. Returns base64-encoded image. + +- **COM**: Win32 window capture (requires Pillow) +- **ezdxf**: matplotlib render (requires matplotlib) + +### view_zoom_and_screenshot +Combined zoom + screenshot in one call. + +| Param | Type | Description | +|-------|------|-------------| +| `x1, y1` | `float \| null` | Optional zoom window corner 1 | +| `x2, y2` | `float \| null` | Optional zoom window corner 2 | + +If coordinates omitted, captures full extents. + +### view_set_view +Set named view. Parameters depend on backend. + +--- + +## Transactions (3) + +Use transactions to group operations with undo support. + +``` +transaction_begin() # Save checkpoint +... make changes ... +transaction_commit() # Accept changes +# or +transaction_rollback() # Revert to checkpoint +``` + +### transaction_begin +Start a transaction. ezdxf saves full DXF snapshot; COM creates undo mark. + +### transaction_commit +Accept all changes since `transaction_begin`. + +### transaction_rollback +Revert all changes since `transaction_begin`. ezdxf restores snapshot; COM uses native undo. + +--- + +## System (6) + +### system_status +No parameters. Returns server status: `{backend, drawing_loaded, entity_count, uptime, ...}`. + +### system_get_variable +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Variable name: `DIMSCALE`, `LTSCALE`, `INSUNITS`, `CLAYER`, `MEASUREMENT` | + +### system_set_variable +| Param | Type | Description | +|-------|------|-------------| +| `name` | `str` | Variable name | +| `value` | `any` | New value | + +### system_run_command +Execute raw AutoCAD command. **COM only**. Sanitized by `sanitize_command()`. + +| Param | Type | Description | +|-------|------|-------------| +| `command` | `str` | Command string (e.g. `_ZOOM E`, `_REGEN`) | + +Dangerous commands blocked unless `DANGEROUS_COMMANDS_ENABLED=true`. + +### system_run_lisp +Evaluate AutoLISP expression. **COM only**. Sanitized by `sanitize_lisp()`. + +| Param | Type | Description | +|-------|------|-------------| +| `expression` | `str` | LISP expression (e.g. `(command "ZOOM" "E")`) | + +Dangerous functions blocked: `startapp`, `vl-file-delete`, `dos_*`. + +### system_about +No parameters. Returns server capabilities, tool count, version, and feature list. diff --git a/.cursor/skills/autocad-mcp/references/troubleshooting.md b/.cursor/skills/autocad-mcp/references/troubleshooting.md new file mode 100644 index 0000000..58c2db5 --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/troubleshooting.md @@ -0,0 +1,79 @@ +# Troubleshooting + +## Common Errors + +### "AutoCAD backend unavailable" +**Cause**: No drawing is loaded — backend needs an active drawing context. +**Fix**: Call `drawing_new()` or `drawing_open(path)` before any other operations. + +### "Entity handle 'XXXX' not found" +**Cause**: Handle doesn't exist, was deleted, or drawing was reloaded. +**Fix**: Use `entity_list()` to get current valid handles. Handles are session-specific in ezdxf; persist by saving/reopening. + +### "Layer 'X' not found" +**Cause**: Layer doesn't exist in the drawing. +**Fix**: `layer_list()` to see existing layers. `layer_create("X")` to create it. + +### "Cannot delete layer '0'" +**Cause**: Layer "0" is the default layer and cannot be removed. +**Fix**: Move entities to another layer instead. Layer "0" always exists. + +### "Path validation failed" +**Cause**: Path contains traversal (`..`) or is outside `ALLOWED_PATHS`. +**Fix**: Use absolute paths within configured allowed directories. Check `ALLOWED_PATHS` env var. + +### "Command rejected by sanitizer" +**Cause**: `system_run_command` detected a dangerous pattern (ERASE, DELETE, etc.). +**Fix**: Use specific tools instead (e.g., `entity_delete` instead of `ERASE` command). Or set `DANGEROUS_COMMANDS_ENABLED=true` if needed. + +### "LISP expression rejected" +**Cause**: `system_run_lisp` detected dangerous function (startapp, file ops). +**Fix**: Use MCP tools instead of LISP for file operations. + +### "COM backend initialization failed" +**Cause**: AutoCAD not running, not installed, or COM registration issues. +**Fix**: Start AutoCAD, then retry. Or switch to ezdxf: `AUTOCAD_MCP_BACKEND=ezdxf`. + +### "View operations are no-ops" +**Cause**: Using ezdxf backend — view/zoom commands only work with COM. +**Fix**: Switch to COM backend if live viewport control is needed. Screenshots still work via matplotlib in ezdxf. + +### "Screenshot failed" +**Cause**: Missing dependency (Pillow for COM, matplotlib for ezdxf). +**Fix**: `pip install Pillow` (COM) or `pip install matplotlib` (ezdxf). + +### "PDF export failed" +**Cause**: matplotlib not installed. +**Fix**: `pip install matplotlib` or `pip install -e ".[pdf]"`. + +### "Transaction not active" +**Cause**: `transaction_commit/rollback` called without `transaction_begin`. +**Fix**: Always call `transaction_begin()` first. + +### "Block 'X' not found" +**Cause**: Block definition doesn't exist. +**Fix**: `block_list()` to see available blocks. Create a block with `block_create_from_entities()`. + +--- + +## Backend-Specific Issues + +### ezdxf Backend +- **No DWG write support**: ezdxf reads DWG but can only write DXF. Use `drawing_export_dxf()`. +- **Undo stack memory**: Full DXF snapshots stored. `MAX_UNDO_STACK` env limits count (default: 50). +- **Text rendering**: Depends on system fonts. MathTex/complex formatting may not render in screenshots. + +### COM Backend +- **STA threading**: All COM calls go through a single thread. Batch operations are sequential. +- **AutoCAD version**: Tested with AutoCAD 2020+. Older versions may have COM API differences. +- **Window capture**: Screenshots capture the AutoCAD window as-is. Minimize overlapping windows. + +--- + +## Diagnostic Steps + +1. `system_status()` — Check backend type, connection, drawing state +2. `system_about()` — See available tools and capabilities +3. `drawing_info()` — Verify drawing is loaded and get metadata +4. `analysis_entity_stats()` — Quick overview of drawing contents +5. Check logs: `LOG_LEVEL=DEBUG` for verbose output diff --git a/.cursor/skills/autocad-mcp/references/workflows.md b/.cursor/skills/autocad-mcp/references/workflows.md new file mode 100644 index 0000000..a7c65dc --- /dev/null +++ b/.cursor/skills/autocad-mcp/references/workflows.md @@ -0,0 +1,166 @@ +# Common Workflows + +## 1. New Drawing from Scratch + +``` +Progress: +- [ ] Step 1: Create drawing +- [ ] Step 2: Set up layers +- [ ] Step 3: Draw entities +- [ ] Step 4: Add dimensions +- [ ] Step 5: Add text annotations +- [ ] Step 6: Verify and save +``` + +**Step 1**: `drawing_new()` — creates empty drawing + +**Step 2**: Create layers for organization: +``` +layer_create("WALLS", color=7, linetype="Continuous") +layer_create("DOORS", color=3) +layer_create("WINDOWS", color=5) +layer_create("DIMENSIONS", color=1) +layer_create("TEXT", color=2) +``` +Or use `template_apply_layers("architectural")` for standard sets. + +**Step 3**: Draw geometry on appropriate layers: +``` +layer_set_current("WALLS") +entity_create_rectangle(x1=0, y1=0, x2=6000, y2=4000) +entity_create_line(x1=3000, y1=0, x2=3000, y2=4000) +``` + +**Step 4**: Add dimensions: +``` +layer_set_current("DIMENSIONS") +dimension_linear(x1=0, y1=0, x2=6000, y2=0, dim_x=3000, dim_y=-500) +``` + +**Step 5**: Add text labels: +``` +layer_set_current("TEXT") +entity_create_text(text="Living Room", x=1500, y=2000, height=200) +``` + +**Step 6**: Verify and save: +``` +analysis_entity_stats() # check entity counts +validation_check() # run quality checks +drawing_save("/path/to/drawing.dxf") +``` + +--- + +## 2. Edit Existing DXF + +``` +Progress: +- [ ] Step 1: Open file +- [ ] Step 2: Explore contents +- [ ] Step 3: Make changes (with transaction) +- [ ] Step 4: Verify and save +``` + +**Step 1**: `drawing_open("/path/to/file.dxf")` + +**Step 2**: Explore: +``` +analysis_entity_stats() # overview +layer_list() # see layers +entity_list(type_filter="LINE", limit=20) # browse entities +``` + +**Step 3**: Begin transaction, then modify: +``` +transaction_begin() +entity_move(handle="1A2B", dx=100, dy=0) +entity_set_properties(handle="3C4D", color=1, layer="WALLS") +entity_delete(handle="5E6F") +transaction_commit() # or transaction_rollback() to undo all +``` + +**Step 4**: `drawing_save()` + +--- + +## 3. Batch Entity Creation + +``` +Progress: +- [ ] Step 1: Prepare entity list +- [ ] Step 2: Batch create +- [ ] Step 3: Verify results +``` + +**Step 1**: Build entity definitions: +```json +[ + {"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0, "layer": "WALLS"}, + {"type": "line", "x1": 100, "y1": 0, "x2": 100, "y2": 100, "layer": "WALLS"}, + {"type": "circle", "cx": 50, "cy": 50, "radius": 10, "layer": "DETAILS"}, + {"type": "text", "text": "A1", "x": 50, "y": -20, "height": 5, "layer": "TEXT"} +] +``` + +**Step 2**: `entity_batch_create(entities=[...])` — returns handles for all created entities + +**Step 3**: `analysis_entity_stats()` to verify counts + +--- + +## 4. Drawing Analysis + +``` +Progress: +- [ ] Step 1: Open drawing +- [ ] Step 2: Get overview stats +- [ ] Step 3: Examine specific areas +- [ ] Step 4: Measure and report +``` + +**Step 1**: `drawing_open(path)` or `drawing_info()` if already open + +**Step 2**: +``` +analysis_entity_stats() # by type and layer +analysis_layer_stats() # per-layer details +analysis_bounding_box() # drawing extents +``` + +**Step 3**: Region queries: +``` +analysis_find_in_region(x1=0, y1=0, x2=1000, y2=1000, type_filter="LINE") +analysis_select_by_layer(layer_name="WALLS") +analysis_select_by_type(entity_type="CIRCLE") +``` + +**Step 4**: Measurements: +``` +analysis_measure_distance(x1=0, y1=0, x2=100, y2=100) +analysis_measure_area(points=[[0,0],[100,0],[100,100],[0,100]]) +``` + +--- + +## 5. Template-Based Drawing + +``` +Progress: +- [ ] Step 1: Create drawing with template +- [ ] Step 2: Apply layer standards +- [ ] Step 3: Add content +- [ ] Step 4: Validate and save +``` + +**Step 1**: `drawing_new(template="/path/to/template.dwt")` (or `drawing_new()` without template) + +**Step 2**: `template_apply_layers("architectural")` — creates standard layers + +**Step 3**: Draw content on appropriate layers + +**Step 4**: +``` +validation_check(["empty_layers", "zero_length"]) +drawing_save("/path/to/output.dxf") +``` diff --git a/.cursor/skills/autocad-mcp/scripts/check_setup.py b/.cursor/skills/autocad-mcp/scripts/check_setup.py new file mode 100644 index 0000000..c9feeb8 --- /dev/null +++ b/.cursor/skills/autocad-mcp/scripts/check_setup.py @@ -0,0 +1,79 @@ +"""Check AutoCAD MCP Pro setup and dependencies.""" + +import importlib +import os +import sys + + +def check(name: str, condition: bool, fix: str = ""): + status = "OK" if condition else "MISSING" + symbol = "[+]" if condition else "[-]" + line = f" {symbol} {name}: {status}" + if not condition and fix: + line += f" -> {fix}" + print(line) + return condition + + +def main(): + print("AutoCAD MCP Pro — Setup Check\n") + ok_count = 0 + total = 0 + + print("Python:") + total += 1 + v = sys.version_info + ok_count += check( + f"Python {v.major}.{v.minor}.{v.micro}", + v >= (3, 11), + "Requires Python 3.11+", + ) + + print("\nRequired packages:") + for pkg, install in [ + ("fastmcp", "pip install fastmcp"), + ("ezdxf", "pip install ezdxf"), + ("pydantic", "pip install pydantic"), + ]: + total += 1 + try: + importlib.import_module(pkg) + ok_count += check(pkg, True) + except ImportError: + ok_count += check(pkg, False, install) + + print("\nOptional packages:") + for pkg, install, purpose in [ + ("win32com", "pip install pywin32", "COM backend"), + ("PIL", "pip install Pillow", "Screenshots (COM)"), + ("matplotlib", "pip install matplotlib", "PDF export + screenshots (ezdxf)"), + ]: + total += 1 + try: + importlib.import_module(pkg) + ok_count += check(f"{pkg} ({purpose})", True) + except ImportError: + ok_count += check(f"{pkg} ({purpose})", False, install) + + print("\nEnvironment variables:") + for var, default in [ + ("AUTOCAD_MCP_BACKEND", "auto"), + ("ALLOWED_PATHS", "(empty = all paths allowed)"), + ("MAX_UNDO_STACK", "50"), + ("LOG_LEVEL", "INFO"), + ("DANGEROUS_COMMANDS_ENABLED", "false"), + ]: + val = os.environ.get(var, "") + total += 1 + ok_count += check(var, True, "") + if val: + print(f" Current value: {val}") + else: + print(f" Default: {default}") + + print(f"\nResult: {ok_count}/{total} checks passed") + return 0 if ok_count >= 4 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/autocad-mcp/scripts/validate_drawing.py b/.cursor/skills/autocad-mcp/scripts/validate_drawing.py new file mode 100644 index 0000000..79c5f86 --- /dev/null +++ b/.cursor/skills/autocad-mcp/scripts/validate_drawing.py @@ -0,0 +1,91 @@ +"""Validate drawing quality — run after creating/editing a drawing. + +Usage: python scripts/validate_drawing.py [drawing_path] + +This script checks for common drawing issues: +- Empty layers (layers with no entities) +- Entities on layer "0" (should use named layers) +- Entity count summary +""" + +import json +import sys + + +def validate_stats(stats: dict) -> list[str]: + """Validate entity statistics. Pass the result of analysis_entity_stats().""" + issues = [] + + if stats.get("total", 0) == 0: + issues.append("WARNING: Drawing has no entities") + + by_layer = stats.get("by_layer", {}) + if "0" in by_layer and by_layer["0"] > 0: + issues.append( + f"INFO: {by_layer['0']} entities on default layer '0' " + "(consider moving to named layers)" + ) + + return issues + + +def validate_layers(layers: list[dict]) -> list[str]: + """Validate layer configuration. Pass the result of layer_list().""" + issues = [] + + for layer in layers: + name = layer.get("name", "") + if name == "0": + continue + if layer.get("color", 7) == 7 and name != "0": + issues.append( + f"INFO: Layer '{name}' uses default color (white/7). " + "Consider assigning a distinct color." + ) + + return issues + + +def main(): + print("Drawing Validation Tool") + print("=" * 40) + print() + print("This tool validates output from MCP analysis tools.") + print("Pipe JSON from analysis_entity_stats() or layer_list().") + print() + print("Usage with MCP:") + print(" 1. Call analysis_entity_stats() → copy JSON output") + print(" 2. Call layer_list() → copy JSON output") + print(" 3. Review results for issues") + print() + print("Checks performed:") + print(" - Empty drawing detection") + print(" - Entities on default layer '0'") + print(" - Layers with default color") + print() + + if not sys.stdin.isatty(): + try: + data = json.load(sys.stdin) + if "total" in data: + issues = validate_stats(data) + elif isinstance(data, list): + issues = validate_layers(data) + else: + issues = ["ERROR: Unrecognized input format"] + + if issues: + for issue in issues: + print(f" {issue}") + else: + print(" OK — No issues found") + except json.JSONDecodeError: + print(" ERROR: Invalid JSON input") + else: + print("No stdin input. Run interactively or pipe JSON data.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3973822 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# AutoCAD MCP Pro — Environment Variables + +# Backend selection: auto | com | ezdxf +AUTOCAD_MCP_BACKEND=auto + +# Comma-separated list of allowed directories for file operations. +# Leave empty to allow all paths (not recommended for production). +# Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD +ALLOWED_PATHS= + +# Maximum undo stack size (number of snapshots to keep) +MAX_UNDO_STACK=50 + +# Logging level: DEBUG | INFO | WARNING | ERROR +LOG_LEVEL=INFO + +# Enable dangerous AutoCAD commands (ERASE ALL, QUIT, etc.) +# Set to true only if you trust all MCP clients +DANGEROUS_COMMANDS_ENABLED=false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec3ef12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -e ".[pdf]" + pip install pytest pytest-asyncio pytest-cov ruff + - name: Run tests + run: pytest --cov=. --cov-report=xml --cov-report=term-missing -v + - name: Upload coverage + if: matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee2efb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Environment +.env + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Project specific +*.dxf +*.dwg +*.bak diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f77a9d5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f0ba6bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Security module (`security.py`) with path traversal protection and command injection prevention +- Centralized configuration (`config.py`) with environment variable support +- `.env.example` for environment variable documentation +- Path validation for all file operations (`drawing_open`, `drawing_save`, `drawing_export_*`) +- Command sanitization for `system_run_command` and `system_run_lisp` +- Ruff linting configuration in `pyproject.toml` +- Pre-commit hooks (`.pre-commit-config.yaml`) for automated code quality +- pytest-cov for test coverage reporting +- Comprehensive test suite: 95 tests across 7 test files + - `test_entity_creation.py` — 13 entity creation tests + - `test_drawing.py` — 8 drawing operation tests + - `test_dimensions.py` — 5 dimension tests + - `test_layer_block.py` — 12 layer and block tests + - `test_analysis.py` — 12 analysis and modification tests + - `test_security.py` — 29 security tests +- GitHub Actions CI workflow (lint + test on Python 3.11/3.12) +- README.md with full documentation +- CHANGELOG.md +- `.gitignore` + +### Changed +- All silent `except Exception: pass` blocks now include logging +- Removed unused imports across all source files +- Renamed ambiguous variable `l` to `lyr` in layer iteration code +- Import sorting organized with ruff isort rules + +### Fixed +- `_detect_autocad_running` now logs detection failures instead of silently swallowing them +- Resource functions now log errors before returning error JSON + +## [1.0.0] — 2026-03-01 + +### Added +- Initial release with 67 tools, 6 resources, 5 prompt templates +- Dual-engine architecture: COM backend (live AutoCAD) + ezdxf backend (headless) +- FastMCP 3.0 server with middleware stack (error handling, audit, timing, logging) +- Drawing management: new, open, save, save-as, export DXF/PDF, purge, audit, undo/redo +- Entity creation: line, circle, arc, polyline, rectangle, text, mtext, hatch, spline, ellipse, point, block reference +- Dimensions: linear, aligned, angular, radius, diameter +- Entity modification: move, copy, rotate, scale, mirror, offset, delete, rectangular/polar array +- Layer management: create, delete, set current, modify, freeze/thaw, lock/unlock, hide/show, isolate +- Block operations: list, insert, explode, attributes, create from entities, find references +- Analysis: entity stats, region search, distance/area measurement, bounding box, select by layer/type +- View control: zoom extents/window, screenshot +- Transaction support: begin, commit, rollback +- System tools: status, variables, command execution, AutoLISP evaluation +- 5 prompt templates: floor plan, P&ID, electrical schematic, mechanical drawing, quick drawing diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9d17526 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +AutoCAD MCP Pro is a FastMCP 3.0 server that exposes 87 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere). + +## Running the Server + +```bash +# STDIO mode (default, for MCP clients like Claude Desktop) +python server.py + +# HTTP mode +fastmcp run server.py:mcp --transport http --port 8000 + +# Force a specific backend +AUTOCAD_MCP_BACKEND=ezdxf python server.py # headless file ops +AUTOCAD_MCP_BACKEND=com python server.py # live AutoCAD (Windows only) +AUTOCAD_MCP_BACKEND=auto python server.py # auto-detect (default) +``` + +## Installation + +```bash +# Core (ezdxf backend only) +pip install -e . + +# With COM backend (Windows + AutoCAD) +pip install -e ".[com]" + +# With PDF/screenshot support via matplotlib +pip install -e ".[pdf]" + +# Everything +pip install -e ".[full]" +``` + +## Running Tests + +```bash +pytest +pytest tests/test_specific.py::test_name # single test +pytest -x # stop on first failure +``` + +Uses `pytest-asyncio` for async test support. + +## Architecture + +### Dual-Backend Pattern + +The server uses a strategy pattern with an abstract base: + +- `backends/base.py` — `AutoCADBackend` ABC defining the full interface + shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`, `CommandResult`) +- `backends/ezdxf_backend.py` — `EzdxfBackend`: file-based DXF operations using the `ezdxf` library. All sync ezdxf calls are wrapped with `asyncio.to_thread` via `_async()`. Transactions are implemented as full DXF snapshots on an `_undo_stack`. +- `backends/com_backend.py` — `ComBackend`: live AutoCAD control via `pywin32` COM. All COM calls are routed through a `ThreadPoolExecutor` with a single thread to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement. + +Backend selection at startup (in `server.py::_make_backend`): +1. Check `AUTOCAD_MCP_BACKEND` env var +2. On Windows with `auto`/`com`: try COM first, fall back to ezdxf +3. On non-Windows: always use ezdxf + +### FastMCP Server Structure (`server.py`) + +The `mcp` FastMCP instance is configured with: +- **Lifespan** (`autocad_lifespan`): initializes the backend singleton; stores it in `ctx.lifespan_context["backend"]` +- **Middleware stack**: `ErrorHandlingMiddleware` → `AuditMiddleware` (custom timing/audit log) → `TimingMiddleware` → `LoggingMiddleware` +- **`_backend(ctx)`** helper: retrieves the backend from lifespan context, raises `ToolError` if not ready + +Tools are organized into 11 sections: +1. Drawing Management (11 tools): `drawing_*` +2. Entity Creation (13 tools): `entity_create_*` +3. Dimensions (5 tools): `dimension_*` +4. Entity Modification (10 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*` +5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many` +6. Layer Management (12 tools): `layer_*` +7. Block Operations (7 tools): `block_*` +8. Analysis & Query (8 tools): `analysis_*` +9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*` +10. Transactions (3 tools): `transaction_begin/commit/rollback` +11. System (6 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about` + +### Adding a New Tool + +1. Add the abstract method to `AutoCADBackend` in `backends/base.py` +2. Implement it in `EzdxfBackend` (`backends/ezdxf_backend.py`) using the `_async(func)` wrapper +3. Implement it in `ComBackend` (`backends/com_backend.py`) using `self._com(func)` +4. Register the tool in `server.py` with `@mcp.tool(...)` calling `_backend(ctx).your_method(...)` +5. Use `_dc(result)` to convert dataclass returns to dicts + +### Key Conventions + +- **Entity handles**: hex strings (e.g. `"1A2B"`). Always returned from create/copy operations; required by all modify/query operations. +- **Coordinates**: drawing units (mm by default); angles in degrees, counter-clockwise from X axis. +- **ACI colors**: 1–255 for specific colors, 256=ByLayer, 0=ByBlock. +- **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf). +- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib. +- **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..294de7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN useradd --create-home --shell /bin/bash mcp + +COPY pyproject.toml ./ +COPY server.py config.py security.py ./ +COPY backends/ ./backends/ + +RUN pip install --no-cache-dir -e . + +USER mcp + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import server; print('ok')" || exit 1 + +CMD ["python", "server.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..06b1dd3 --- /dev/null +++ b/README.md @@ -0,0 +1,411 @@ +# AutoCAD MCP Pro + +**Production-grade AutoCAD automation for the Model Context Protocol.** +Dual-engine. Battle-tested. Model-agnostic. + +87 tools · 6 resources · 5 prompt templates · COM + ezdxf backends · Python 3.11+ · MIT + +--- + +## From the Author + +I built **AutoCAD MCP Pro** because I needed it. + +My day job at **Anka-Makine** revolves around intensive AutoCAD work — production drawings, parametric assemblies, strict layer standards across dozens of templates, dimensioning and block work under tight schedules, repeated audits, exports, and screenshots for design reviews. The repetitive parts of that workflow add up fast. + +After months of leaning on this server inside my own daily workflow — and watching it shave hours off my week without a single misstep on critical drawings — I decided it deserved a public release. It works. It is fast. It stays out of the way. + +The model on the other end of the wire does not matter. The Model Context Protocol is the contract; AutoCAD MCP Pro is one well-typed implementation of that contract. Any MCP-aware client — and the LLM behind it — sees the same 87 tools. + +I will keep this repository actively maintained as my own use of it evolves, and **a public benchmark suite is on the way** — I want to give you numbers, not adjectives. + +— *Umutcan Edizsalan · Anka-Makine* + +--- + +## Table of Contents + +- [Why This Exists](#why-this-exists) +- [Highlights](#highlights) +- [AI Client Compatibility](#ai-client-compatibility) +- [Quickstart](#quickstart) +- [Installation](#installation) +- [MCP Client Configuration](#mcp-client-configuration) +- [Backend Selection](#backend-selection) +- [Configuration Reference](#configuration-reference) +- [Tool Catalog](#tool-catalog) +- [Architecture](#architecture) +- [Security Model](#security-model) +- [Benchmarks](#benchmarks) +- [Development](#development) +- [Roadmap](#roadmap) +- [Author](#author) +- [License](#license) + +--- + +## Why This Exists + +Most CAD-automation tooling falls into two camps: heavyweight enterprise plug-ins that lock you into a single vendor, or one-off Python scripts that solve a single drawing and rot a week later. Neither plays well with the modern AI workflow, where the *agent* — not the engineer — is the one driving the tool. + +AutoCAD MCP Pro takes a different stance: + +1. **One protocol, two engines.** A live **COM** backend talks to a running AutoCAD instance for in-app, real-time control. A headless **ezdxf** backend reads and writes DXF files anywhere — Linux, macOS, CI runners — with no AutoCAD installation required. Same tool surface, same response shapes, two execution modes. +2. **Built for agents.** Every tool has a typed schema, a deterministic error contract, and structured progress updates. No string-parsing tricks; no positional-argument guessing. Pydantic on the way in, dataclasses on the way out. +3. **Hardened by default.** Path traversal, command injection, AutoLISP escape, transaction-driven memory blowups, COM hangs — all addressed in the default configuration. You opt **in** to risk, never out. + +--- + +## Highlights + +- **Dual Engine Architecture** + - **COM backend**: live AutoCAD control via the Win32 COM API, routed through a single-thread STA executor with a per-call timeout so an unresponsive AutoCAD never hangs the server. + - **ezdxf backend**: headless DXF file operations powered by [ezdxf](https://github.com/mozman/ezdxf). Works on every platform, ideal for batch workloads and CI pipelines. + - Automatic backend selection, with a clean override via `AUTOCAD_MCP_BACKEND`. + +- **87 Tools, 11 Categories** + - Drawing management — `drawing_new`, `drawing_open`, `drawing_save`, `drawing_save_as`, `drawing_export_dxf`, `drawing_export_pdf`, `drawing_purge`, `drawing_audit`, `drawing_undo`, `drawing_redo`, `drawing_close` + - Entity creation — line, circle, arc, polyline, rectangle, text, mtext, hatch, spline, ellipse, point, block reference, batch create + - Dimensions — linear, aligned, angular, radius, diameter + - Entity modification — move, copy, rotate, scale, mirror, offset, delete, rectangular array, polar array, batch modify, set properties + - Layer management — full lifecycle: create, delete, modify, freeze/thaw, lock/unlock, hide/show, isolate, set current + - Block operations — list, insert, explode, attribute get/set, create-from-entities, find references + - Analysis — entity stats, region select, distance/area measurement, bounding box, select by type/layer, layer statistics + - View control — zoom extents, zoom window, screenshot, combined zoom-and-screenshot + - Transactions — begin, commit, rollback, with disk-backed snapshots + - System — status, get/set variables, run command, run AutoLISP, about + - Templates and validation — apply standard layer templates, validate drawings against rule sets + +- **Production-Grade Plumbing** + - FastMCP 3.0 lifespan-managed backend singleton + - Middleware stack: error handling, audit logging, timing, request logging + - Structured progress reports for long-running operations (`drawing_open`, exports, batch ops) + - 104 tests across drawing, entity, dimension, layer, block, analysis, batch/template, security suites + - Ruff-clean codebase, GitHub Actions CI + +- **Security-First Defaults** + - Path traversal & system-directory protection + - AutoLISP allowlist with regression tests for known bypass patterns (`vla-SendCommand`, `acet-*`, alias tricks, `funcall` indirection, `c:` custom commands, error-handler hijacks) + - Command sanitization for `system_run_command` + - HTTP transport refuses non-loopback bind without an explicit opt-in **and** a configured auth token + - Configurable file size limit on DXF input (`MAX_DXF_BYTES`) blocks decompression-bomb-style attacks + - Per-call COM timeout (`COM_CALL_TIMEOUT`) prevents a hung AutoCAD from freezing the server + +--- + +## AI Client Compatibility + +AutoCAD MCP Pro implements the Model Context Protocol. **If a host can speak MCP, it can drive this server.** No model-specific code, no per-vendor patches — the contract is the tool schema, and any MCP-aware client (and any LLM behind it) inherits the full 87-tool surface for free. + +| Host | Status | Notes | +|-----------------------------|-------------|------------------------------------| +| Claude Desktop | Verified | Native MCP support | +| Cursor | Verified | Native MCP support | +| Cline / Roo Code (VS Code) | Verified | Native MCP support | +| Continue | Verified | MCP via configuration | +| Goose | Verified | MCP via configuration | +| Custom MCP clients | Supported | STDIO and HTTP transports | + +--- + +## Quickstart + +```bash +git clone https://github.com/U-C4N/autocad-mcp.git +cd autocad-mcp +pip install -e ".[full]" +python server.py +``` + +That is it. The server starts in STDIO mode and your MCP client will discover all 87 tools. + +For headless / CI workflows: + +```bash +AUTOCAD_MCP_BACKEND=ezdxf python server.py +``` + +For HTTP transport (local-only by default — see [Security Model](#security-model)): + +```bash +fastmcp run server.py:mcp --transport http --port 8000 +``` + +--- + +## Installation + +### Core (ezdxf backend only — works everywhere) + +```bash +pip install -e . +``` + +### With live AutoCAD control (Windows + AutoCAD) + +```bash +pip install -e ".[com]" +``` + +### With PDF export and matplotlib-based screenshots + +```bash +pip install -e ".[pdf]" +``` + +### Everything + +```bash +pip install -e ".[full]" +``` + +### Development + +```bash +pip install -e ".[full]" +pip install pytest pytest-asyncio pytest-cov ruff +``` + +--- + +## MCP Client Configuration + +### Claude Desktop + +Edit `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "autocad": { + "command": "python", + "args": ["C:/path/to/autocad-mcp/server.py"], + "env": { + "AUTOCAD_MCP_BACKEND": "auto", + "ALLOWED_PATHS": "C:/Users/you/Documents/AutoCAD" + } + } + } +} +``` + +### Cursor + +`Settings → MCP → Add new server` and point at `python /path/to/server.py`. + +### Cline / Continue / Goose + +All standard MCP `command` + `args` configuration; same shape as Claude Desktop. + +--- + +## Backend Selection + +| `AUTOCAD_MCP_BACKEND` | Behavior | +|-----------------------|---------------------------------------------------------------------------------------| +| `auto` *(default)* | Try COM first on Windows, fall back to ezdxf if AutoCAD is not reachable. | +| `com` | Force COM. Fails fast if AutoCAD is not running. | +| `ezdxf` | Force headless ezdxf. Works on Linux, macOS, Windows with no AutoCAD. | + +--- + +## Configuration Reference + +Copy `.env.example` to `.env` and edit: + +| Variable | Default | Purpose | +|--------------------------------|----------------------|-----------------------------------------------------------------------| +| `AUTOCAD_MCP_BACKEND` | `auto` | Backend selection (`auto`, `com`, `ezdxf`). | +| `LOG_LEVEL` | `INFO` | Standard Python logging level. | +| `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may read/write. | +| `MAX_UNDO_STACK` | `5` | Maximum number of in-flight transaction snapshots. | +| `MAX_DXF_BYTES` | `52428800` (50 MB) | Reject DXF files larger than this on `drawing_open`. `0` to disable. | +| `MAX_LIST_LIMIT` | `5000` | Hard cap for `entity_list` and `analysis_select_*` responses. | +| `COM_CALL_TIMEOUT` | `60` (seconds) | Per-call timeout for COM operations. Prevents a hung AutoCAD blocking the server. | +| `DANGEROUS_COMMANDS_ENABLED` | `false` | Disable command and AutoLISP sanitization. **Loud warning at startup.** | +| `ALLOW_REMOTE_HTTP` | `false` | Permit HTTP transport on non-loopback hosts. | +| `MCP_AUTH_TOKEN` | *(empty)* | Required when `ALLOW_REMOTE_HTTP=true`. Bearer token for HTTP clients. | + +--- + +## Tool Catalog + +| Group | Count | Sample tools | +|---------------------|------:|---------------------------------------------------------------------------| +| Drawing | 11 | `drawing_new`, `drawing_open`, `drawing_save`, `drawing_export_pdf`, `drawing_purge`, `drawing_close` | +| Entity Creation | 13 | `entity_create_line`, `entity_create_polyline`, `entity_create_hatch`, `entity_create_block_ref` | +| Dimensions | 5 | `dimension_linear`, `dimension_aligned`, `dimension_angular`, `dimension_radius`, `dimension_diameter`| +| Entity Modification | 10 | `entity_move`, `entity_rotate`, `entity_array_polar`, `entity_offset`, `entity_array_rectangular` | +| Entity Query | 3 | `entity_get`, `entity_list`, `entity_delete_many` | +| Layer Management | 12 | `layer_create`, `layer_freeze`, `layer_isolate`, `layer_modify` | +| Block Operations | 7 | `block_insert`, `block_explode`, `block_create_from_entities`, `block_find_references` | +| Analysis | 8 | `analysis_entity_stats`, `analysis_bounding_box`, `analysis_select_by_layer`, `analysis_layer_stats` | +| View | 4 | `view_zoom_extents`, `view_screenshot`, `view_zoom_and_screenshot` | +| Transactions | 3 | `transaction_begin`, `transaction_commit`, `transaction_rollback` | +| System | 6 | `system_status`, `system_run_command`, `system_run_lisp`, `system_about` | +| Batch / Templates | 5 | `entity_batch_create`, `entity_batch_modify`, `template_apply_layers`, `validation_check` | + +`system_about` returns the live tool inventory — the count above is illustrative; the runtime number is authoritative. + +--- + +## Architecture + +``` +server.py FastMCP 3.0 server (lifespan, middleware, tools) +│ +├── config.py Centralized environment-driven configuration +├── security.py Path validation, command + LISP sanitization +│ +└── backends/ + ├── base.py AutoCADBackend ABC + shared dataclasses + │ (EntityInfo, LayerInfo, BlockInfo, DrawingInfo, CommandResult) + ├── ezdxf_backend.py Headless DXF/DWG via ezdxf + │ sync calls wrapped in asyncio.to_thread + │ transaction snapshots persisted to temp files + └── com_backend.py Live AutoCAD via pywin32 + single-thread STA executor with per-call timeout + UUID-named selection sets, leak-free cleanup +``` + +The server boots once, the backend is initialized in the FastMCP lifespan, and every tool call is dispatched through the middleware stack: + +`ErrorHandlingMiddleware → AuditMiddleware → TimingMiddleware → LoggingMiddleware → tool` + +Tool returns are flowed back through `_dc()`, which converts dataclasses to JSON-serializable dicts and preserves the typed contract on the wire. + +--- + +## Security Model + +AutoCAD MCP Pro assumes a hostile-input threat model: the MCP client may be untrusted, the model behind it may be jailbroken, and the inputs to every tool may be adversarial. + +- **AutoLISP allowlist** — `security.py` blocks the full ActiveX/COM family (`vla-*`, `vlax-*`, `vlr-*`), Express Tools (`acet-*`), command channels (`command`, `command-s`, `vl-cmdf`), file I/O, code loading, indirection vectors (`funcall`, `function`, `apply`, `eval`), custom command invocation (`c:*`), and error-handler hijacks. Every known bypass is covered by a regression test. +- **Command sanitization** — destructive AutoCAD verbs (`ERASE`, `PURGE`, `SHELL`, `SCRIPT`, `APPLOAD`, `NETLOAD`, `VBARUN`, …) are rejected unless `DANGEROUS_COMMANDS_ENABLED=true` is set, in which case the server logs a loud `WARNING` at startup and surfaces an `unsafe_mode` flag in `system_status` and `system_about`. +- **Path validation** — every tool that takes a path runs through `validate_path()`, which blocks traversal patterns, system directories, and constrains writes to a configurable `ALLOWED_PATHS` list. +- **HTTP transport guard** — by default the server refuses to bind anything other than `127.0.0.1`. To bind a non-loopback host you must opt in via `ALLOW_REMOTE_HTTP=true` **and** set `MCP_AUTH_TOKEN`. +- **Resource exhaustion guards** — `MAX_DXF_BYTES` rejects oversize input, `MAX_LIST_LIMIT` caps response sizes, transaction snapshots persist to disk so memory stays bounded, and `COM_CALL_TIMEOUT` keeps a hung AutoCAD from taking the server with it. + +If you find a security issue, please open a private contact rather than a public issue. + +--- + +## Benchmarks + +A reproducible benchmark suite is in active development. Targets: + +- **Drawing scale** — 1k / 10k / 100k entity workloads +- **Workload classes** — bulk creation, mass-modify, region select, render-and-screenshot, end-to-end design pass +- **Backend variants** — ezdxf on CPython 3.11/3.12, COM against AutoCAD 2024+ +- **Model compatibility** — tool-call success rate and prompt token usage by model, on a fixed set of canonical CAD prompts + +Numbers will land in [`benchmarks/`](benchmarks/) with a one-command runner and a hardware footprint. The table below will be updated as new models and AutoCAD versions release. + +| Workload | ezdxf (3.12) | COM (AutoCAD 2025) | Notes | +|--------------------------------|--------------|--------------------|-------| +| Open + audit 10k-entity DXF | *coming* | *coming* | | +| Bulk-create 5k LINE entities | *coming* | *coming* | | +| Region select on 100k drawing | *coming* | *coming* | | +| End-to-end "design pass" prompt| *coming* | *coming* | by model family | + +> Have a workload you want included? Open an issue. + +--- + +## Development + +### Run tests + +```bash +pytest -v +``` + +### Coverage report + +```bash +pytest --cov=. --cov-report=term-missing +``` + +### Lint and format + +```bash +ruff check . +ruff format --check . +``` + +### Project structure + +``` +autocad-mcp/ +├── server.py +├── config.py +├── security.py +├── backends/ +│ ├── base.py +│ ├── ezdxf_backend.py +│ └── com_backend.py +├── tests/ +│ ├── test_drawing.py +│ ├── test_entity_creation.py +│ ├── test_dimensions.py +│ ├── test_layer_block.py +│ ├── test_analysis.py +│ ├── test_batch_template.py +│ ├── test_ezdxf_backend.py +│ └── test_security.py +├── pyproject.toml +├── Dockerfile +└── .github/workflows/ci.yml +``` + +--- + +## Roadmap + +- [x] **1.0** — Initial public release +- [x] **1.1** — Security hardening (LISP allowlist, transaction snapshots on disk, COM timeouts, HTTP bind guard, dynamic tool count) +- [ ] **1.2** — Public benchmark suite (`benchmarks/` with reproducible runner) +- [ ] **1.3** — Backend `capabilities()` map and per-tool enablement (e.g. hide `system_run_command` automatically on ezdxf) +- [ ] **1.4** — Module split for `server.py` and a clean `services/` orchestration layer +- [ ] **1.5** — Windows CI matrix with mocked COM backend; full coverage report +- [ ] **2.0** — Multi-document context, xref-aware tools, layout/paper-space first-class support +- [ ] **2.x** — Native Linux/macOS support for live control via [ODA File Converter](https://www.opendesign.com/guestfiles/oda_file_converter) for round-trip DWG + +--- + +## Requirements + +- Python **3.11+** +- [FastMCP](https://github.com/jlowin/fastmcp) **3.0+** +- [ezdxf](https://github.com/mozman/ezdxf) **1.3+** +- *(optional, COM backend)* `pywin32` **306+**, AutoCAD 2020 or newer +- *(optional, screenshots)* `Pillow` **10.0+** +- *(optional, PDF export)* `matplotlib` **3.8+** + +--- + +## Contributing + +Pull requests are welcome — especially around the COM backend (mocking, additional Windows tooling), benchmark suite contributions, and additional layer / dimension standards. + +For non-trivial changes, please open an issue first to align on direction. + +--- + +## Author + +**Umutcan Edizsalan** +Mechanical / engineering work at **Anka-Makine**. +Builds tools that survive contact with real production drawings. + +GitHub: [@U-C4N](https://github.com/U-C4N) + +This project exists because it earned its place in my own daily workflow. I will keep it maintained and aligned with my real usage. + +--- + +## License + +MIT — see [LICENSE](LICENSE). + +Copyright © 2026 Umutcan Edizsalan. diff --git a/backends/__init__.py b/backends/__init__.py new file mode 100644 index 0000000..4a3165f --- /dev/null +++ b/backends/__init__.py @@ -0,0 +1,15 @@ +"""AutoCAD MCP Pro – backend package.""" +from .base import AutoCADBackend, BlockInfo, CommandResult, DrawingInfo, EntityInfo, LayerInfo +from .com_backend import ComBackend +from .ezdxf_backend import EzdxfBackend + +__all__ = [ + "AutoCADBackend", + "CommandResult", + "EntityInfo", + "LayerInfo", + "BlockInfo", + "DrawingInfo", + "ComBackend", + "EzdxfBackend", +] diff --git a/backends/base.py b/backends/base.py new file mode 100644 index 0000000..dc55f8e --- /dev/null +++ b/backends/base.py @@ -0,0 +1,476 @@ +"""Abstract base class + shared data models for AutoCAD backends.""" + +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +# --------------------------------------------------------------------------- +# Data models +# --------------------------------------------------------------------------- + + +@dataclass +class CommandResult: + ok: bool + payload: Any = None + error: str | None = None + + def to_dict(self) -> dict: + d: dict[str, Any] = {"ok": self.ok} + if self.ok: + d["payload"] = self.payload + else: + d["error"] = self.error + return d + + +@dataclass +class EntityInfo: + handle: str + type: str # e.g. "LINE", "CIRCLE", "ARC" + layer: str + color: int # ACI color (256=ByLayer) + linetype: str + visible: bool + properties: dict = field(default_factory=dict) + + +@dataclass +class LayerInfo: + name: str + color: int + linetype: str + lineweight: float + is_on: bool + is_frozen: bool + is_locked: bool + is_current: bool + + +@dataclass +class BlockInfo: + name: str + origin: tuple[float, float] + attribute_count: int + entity_count: int + is_xref: bool + description: str = "" + + +@dataclass +class DrawingInfo: + name: str + full_path: str + saved: bool + entity_count: int + layer_count: int + block_count: int + extents_min: tuple[float, float] + extents_max: tuple[float, float] + units: str + version: str = "" + backend: str = "" + + +# --------------------------------------------------------------------------- +# Abstract backend +# --------------------------------------------------------------------------- + + +class AutoCADBackend(ABC): + """All backends implement this interface.""" + + # ── identity ──────────────────────────────────────────────────────────── + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def is_connected(self) -> bool: ... + + @abstractmethod + async def connect(self) -> None: ... + + @abstractmethod + async def disconnect(self) -> None: ... + + # ── drawing management ─────────────────────────────────────────────────── + @abstractmethod + async def drawing_info(self) -> DrawingInfo: ... + + @abstractmethod + async def drawing_new(self, template: str | None = None) -> dict: ... + + @abstractmethod + async def drawing_open(self, path: str) -> dict: ... + + @abstractmethod + async def drawing_save(self, path: str | None = None) -> dict: ... + + @abstractmethod + async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ... + + @abstractmethod + async def drawing_export_dxf(self, path: str) -> dict: ... + + @abstractmethod + async def drawing_export_pdf(self, path: str) -> dict: ... + + @abstractmethod + async def drawing_purge(self) -> dict: ... + + @abstractmethod + async def drawing_audit(self) -> dict: ... + + @abstractmethod + async def drawing_close(self, save: bool = True) -> dict: ... + + @abstractmethod + async def drawing_undo(self) -> dict: ... + + @abstractmethod + async def drawing_redo(self) -> dict: ... + + # ── entity creation ────────────────────────────────────────────────────── + @abstractmethod + async def entity_create_line( + self, x1: float, y1: float, x2: float, y2: float, + z1: float = 0.0, z2: float = 0.0, + layer: str | None = None, color: int | None = None, + linetype: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_circle( + self, cx: float, cy: float, radius: float, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_arc( + self, cx: float, cy: float, radius: float, + start_angle: float, end_angle: float, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_polyline( + self, points: list[list[float]], closed: bool = False, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_text( + self, text: str, x: float, y: float, + height: float = 2.5, rotation: float = 0.0, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_mtext( + self, text: str, x: float, y: float, + width: float = 100.0, height: float = 2.5, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_hatch( + self, pattern: str, boundary_points: list[list[float]], + scale: float = 1.0, angle: float = 0.0, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_spline( + self, fit_points: list[list[float]], + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_ellipse( + self, cx: float, cy: float, + major_x: float, major_y: float, ratio: float = 0.5, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_point( + self, x: float, y: float, + layer: str | None = None, color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_block_ref( + self, name: str, x: float, y: float, + scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0, + layer: str | None = None, + ) -> EntityInfo: ... + + # ── dimensions ─────────────────────────────────────────────────────────── + @abstractmethod + async def dimension_linear( + self, x1: float, y1: float, x2: float, y2: float, + dim_x: float, dim_y: float, rotation: float = 0.0, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_aligned( + self, x1: float, y1: float, x2: float, y2: float, + dim_x: float, dim_y: float, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_angular( + self, vx: float, vy: float, + x1: float, y1: float, x2: float, y2: float, + tx: float, ty: float, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_radius( + self, cx: float, cy: float, chord_x: float, chord_y: float, + leader_length: float = 10.0, layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_diameter( + self, x1: float, y1: float, x2: float, y2: float, + leader_length: float = 10.0, layer: str | None = None, + ) -> EntityInfo: ... + + # ── entity modification ────────────────────────────────────────────────── + @abstractmethod + async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ... + + @abstractmethod + async def entity_copy(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> EntityInfo: ... + + @abstractmethod + async def entity_rotate( + self, handle: str, base_x: float, base_y: float, angle_deg: float, + ) -> dict: ... + + @abstractmethod + async def entity_scale( + self, handle: str, base_x: float, base_y: float, factor: float, + ) -> dict: ... + + @abstractmethod + async def entity_mirror( + self, handle: str, + x1: float, y1: float, x2: float, y2: float, + delete_original: bool = False, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_offset( + self, handle: str, distance: float, + side_x: float | None = None, side_y: float | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_delete(self, handle: str) -> dict: ... + + @abstractmethod + async def entity_array_rectangular( + self, handle: str, + rows: int, cols: int, row_spacing: float, col_spacing: float, + ) -> list[EntityInfo]: ... + + @abstractmethod + async def entity_array_polar( + self, handle: str, + count: int, fill_angle: float, + center_x: float, center_y: float, + ) -> list[EntityInfo]: ... + + # ── entity query/properties ─────────────────────────────────────────────── + @abstractmethod + async def entity_get(self, handle: str) -> EntityInfo: ... + + @abstractmethod + async def entity_set_properties( + self, handle: str, + layer: str | None = None, + color: int | None = None, + linetype: str | None = None, + lineweight: float | None = None, + visible: bool | None = None, + ) -> dict: ... + + @abstractmethod + async def entity_list( + self, + type_filter: str | None = None, + layer_filter: str | None = None, + limit: int = 200, + offset: int = 0, + ) -> list[EntityInfo]: ... + + # ── layer management ───────────────────────────────────────────────────── + @abstractmethod + async def layer_list(self) -> list[LayerInfo]: ... + + @abstractmethod + async def layer_create( + self, name: str, + color: int = 7, + linetype: str = "Continuous", + lineweight: float = -3, + ) -> LayerInfo: ... + + @abstractmethod + async def layer_delete(self, name: str) -> dict: ... + + @abstractmethod + async def layer_set_current(self, name: str) -> dict: ... + + @abstractmethod + async def layer_modify( + self, name: str, + color: int | None = None, + linetype: str | None = None, + lineweight: float | None = None, + ) -> LayerInfo: ... + + @abstractmethod + async def layer_freeze(self, name: str) -> dict: ... + + @abstractmethod + async def layer_thaw(self, name: str) -> dict: ... + + @abstractmethod + async def layer_lock(self, name: str) -> dict: ... + + @abstractmethod + async def layer_unlock(self, name: str) -> dict: ... + + @abstractmethod + async def layer_hide(self, name: str) -> dict: ... + + @abstractmethod + async def layer_show(self, name: str) -> dict: ... + + # ── block operations ───────────────────────────────────────────────────── + @abstractmethod + async def block_list(self) -> list[BlockInfo]: ... + + @abstractmethod + async def block_insert( + self, name: str, x: float, y: float, + scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0, + attributes: dict | None = None, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def block_explode(self, handle: str) -> dict: ... + + @abstractmethod + async def block_get_attributes(self, handle: str) -> dict: ... + + @abstractmethod + async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ... + + @abstractmethod + async def block_create_from_entities( + self, name: str, handles: list[str], + base_x: float = 0.0, base_y: float = 0.0, + ) -> dict: ... + + # ── analysis / query ───────────────────────────────────────────────────── + @abstractmethod + async def analysis_stats(self) -> dict: ... + + @abstractmethod + async def analysis_entities_in_region( + self, x1: float, y1: float, x2: float, y2: float, + ) -> list[EntityInfo]: ... + + @abstractmethod + async def analysis_measure_distance( + self, x1: float, y1: float, x2: float, y2: float, + ) -> float: ... + + @abstractmethod + async def analysis_measure_area(self, points: list[list[float]]) -> float: ... + + @abstractmethod + async def analysis_bounding_box(self) -> dict: ... + + @abstractmethod + async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ... + + @abstractmethod + async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ... + + # ── view / screenshot ──────────────────────────────────────────────────── + @abstractmethod + async def view_zoom_extents(self) -> dict: ... + + @abstractmethod + async def view_zoom_window( + self, x1: float, y1: float, x2: float, y2: float, + ) -> dict: ... + + @abstractmethod + async def view_screenshot(self) -> bytes | None: ... + + # ── transactions ───────────────────────────────────────────────────────── + @abstractmethod + async def transaction_begin(self) -> dict: ... + + @abstractmethod + async def transaction_commit(self) -> dict: ... + + @abstractmethod + async def transaction_rollback(self) -> dict: ... + + # ── system ─────────────────────────────────────────────────────────────── + @abstractmethod + async def system_status(self) -> dict: ... + + @abstractmethod + async def system_get_variable(self, name: str) -> Any: ... + + @abstractmethod + async def system_set_variable(self, name: str, value: Any) -> dict: ... + + @abstractmethod + async def system_run_command(self, command: str) -> dict: ... + + @abstractmethod + async def system_run_lisp(self, expression: str) -> dict: ... + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def shoelace_area(points: list[list[float]]) -> float: + """Calculate polygon area using the shoelace formula.""" + n = len(points) + if n < 3: + return 0.0 + area = 0.0 + for i in range(n): + j = (i + 1) % n + area += points[i][0] * points[j][1] + area -= points[j][0] * points[i][1] + return abs(area) / 2.0 + + +def deg2rad(degrees: float) -> float: + return degrees * math.pi / 180.0 + + +def rad2deg(radians: float) -> float: + return radians * 180.0 / math.pi diff --git a/backends/com_backend.py b/backends/com_backend.py new file mode 100644 index 0000000..4fa2447 --- /dev/null +++ b/backends/com_backend.py @@ -0,0 +1,1362 @@ +"""AutoCAD COM backend – live AutoCAD control via pywin32. + +All COM calls are routed through a single-threaded executor to satisfy +AutoCAD's STA (Single-Threaded Apartment) COM requirement. +""" + +from __future__ import annotations + +import asyncio +import io +import logging +import math +import sys +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import config + +from .base import ( + AutoCADBackend, + BlockInfo, + DrawingInfo, + EntityInfo, + LayerInfo, + deg2rad, + rad2deg, + shoelace_area, +) + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Optional COM imports +# --------------------------------------------------------------------------- + +_WIN32_AVAILABLE = sys.platform == "win32" + +if _WIN32_AVAILABLE: + try: + import pythoncom + import pywintypes + import win32com.client + import win32con # noqa: F401 + import win32gui + import win32ui + _COM_IMPORTS_OK = True + except ImportError: + _COM_IMPORTS_OK = False +else: + _COM_IMPORTS_OK = False + +try: + from PIL import Image as PILImage + _PIL_OK = True +except ImportError: + _PIL_OK = False + + +# --------------------------------------------------------------------------- +# COM thread-state (accessed ONLY from the COM executor thread) +# --------------------------------------------------------------------------- + +_COM_STATE: dict[str, Any] = {} # keys: "app" + + +def _com_init(): + """COM thread initializer – called once by the ThreadPoolExecutor.""" + if _COM_IMPORTS_OK: + pythoncom.CoInitialize() + + +def _apoint(x: float, y: float, z: float = 0.0): + """Create a VARIANT double-array point for AutoCAD COM.""" + return win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + [float(x), float(y), float(z)], + ) + + +def _av(values: list[float]): + """Create a VARIANT double-array from a flat list.""" + return win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + [float(v) for v in values], + ) + + +def _ai(values: list[int]): + """Create a VARIANT short-int array.""" + return win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_I2, + list(values), + ) + + +def _acad_app(): + """Return (or lazily create) the AutoCAD Application COM object. + Must only be called from the COM executor thread.""" + if "app" not in _COM_STATE: + try: + _COM_STATE["app"] = win32com.client.GetActiveObject("AutoCAD.Application") + except Exception as exc: + log.debug("GetActiveObject failed, trying Dispatch: %s", exc) + try: + _COM_STATE["app"] = win32com.client.Dispatch("AutoCAD.Application") + _COM_STATE["app"].Visible = True + except Exception as exc: + raise RuntimeError( + f"Cannot connect to AutoCAD: {exc}. " + "Make sure AutoCAD is installed and running." + ) from exc + return _COM_STATE["app"] + + +def _acad_doc(): + """Return active AutoCAD document.""" + app = _acad_app() + if app.Documents.Count == 0: + raise RuntimeError("No drawing is open in AutoCAD. Open or create a .dwg file first.") + return app.ActiveDocument + + +def _msp(): + """Return ModelSpace of active document.""" + return _acad_doc().ModelSpace + + +def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None): + """Apply common entity attributes after creation.""" + if layer is not None: + entity.Layer = layer + if color is not None: + entity.Color = int(color) + if linetype is not None: + entity.Linetype = linetype + + +def _regen(): + """Regen the active viewport so new entities appear immediately on screen.""" + if _COM_STATE.get("batch_mode"): + return + try: + _acad_doc().Regen(0) # 0 = acActiveViewport + except Exception as exc: + log.debug("Regen failed: %s", exc) + + +def _entity_info(entity) -> EntityInfo: + """Convert a COM entity object to EntityInfo dataclass.""" + try: + bb_min, bb_max = entity.GetBoundingBox() + props = { + "bounding_box": { + "min": [bb_min[0], bb_min[1]], + "max": [bb_max[0], bb_max[1]], + } + } + except Exception as exc: + log.debug("GetBoundingBox failed for entity: %s", exc) + props = {} + + # Add type-specific properties + obj_name = entity.ObjectName + try: + if obj_name == "AcDbLine": + sp = entity.StartPoint + ep = entity.EndPoint + props["start"] = [sp[0], sp[1]] + props["end"] = [ep[0], ep[1]] + props["length"] = entity.Length + elif obj_name == "AcDbCircle": + ctr = entity.Center + props["center"] = [ctr[0], ctr[1]] + props["radius"] = entity.Radius + elif obj_name == "AcDbArc": + ctr = entity.Center + props["center"] = [ctr[0], ctr[1]] + props["radius"] = entity.Radius + props["start_angle"] = rad2deg(entity.StartAngle) + props["end_angle"] = rad2deg(entity.EndAngle) + elif obj_name in ("AcDbLWPolyline", "AcDb2dPolyline"): + coords = list(entity.Coordinates) + pts = [[coords[i], coords[i+1]] for i in range(0, len(coords), 2)] + props["points"] = pts + props["closed"] = bool(entity.Closed) + props["length"] = entity.Length + elif obj_name == "AcDbText": + props["text"] = entity.TextString + ins = entity.InsertionPoint + props["insertion"] = [ins[0], ins[1]] + props["height"] = entity.Height + elif obj_name == "AcDbMText": + props["text"] = entity.Contents + ins = entity.InsertionPoint + props["insertion"] = [ins[0], ins[1]] + elif obj_name == "AcDbBlockReference": + ins = entity.InsertionPoint + props["insertion"] = [ins[0], ins[1]] + props["block_name"] = entity.Name + props["x_scale"] = entity.XScaleFactor + props["y_scale"] = entity.YScaleFactor + props["rotation_deg"] = rad2deg(entity.Rotation) + except Exception as exc: + log.debug("Type-specific entity properties extraction failed: %s", exc) + + ent_type = obj_name.replace("AcDb", "").upper() + try: + linetype = entity.Linetype + except Exception as exc: + log.debug("Linetype read failed, using ByLayer: %s", exc) + linetype = "ByLayer" + + return EntityInfo( + handle=entity.Handle, + type=ent_type, + layer=entity.Layer, + color=entity.Color, + linetype=linetype, + visible=bool(entity.Visible), + properties=props, + ) + + +def _layer_info(layer, current_layer_name: str) -> LayerInfo: + """Convert a COM layer object to LayerInfo.""" + return LayerInfo( + name=layer.Name, + color=layer.Color, + linetype=layer.Linetype, + lineweight=layer.LineWeight, + is_on=bool(layer.LayerOn), + is_frozen=bool(layer.Freeze), + is_locked=bool(layer.Lock), + is_current=(layer.Name == current_layer_name), + ) + + +# --------------------------------------------------------------------------- +# Screenshot helpers (COM thread only) +# --------------------------------------------------------------------------- + +def _find_autocad_hwnd() -> int | None: + """Find the main AutoCAD window handle.""" + if not _WIN32_AVAILABLE: + return None + result = {"hwnd": None} + + def _enum_cb(hwnd, _): + title = win32gui.GetWindowText(hwnd) + if "AutoCAD" in title and win32gui.IsWindowVisible(hwnd): + result["hwnd"] = hwnd + return False + return True + + try: + win32gui.EnumWindows(_enum_cb, None) + except Exception as exc: + log.debug("EnumWindows failed while finding AutoCAD hwnd: %s", exc) + return result["hwnd"] + + +def _capture_window(hwnd: int) -> bytes | None: + """Capture an AutoCAD window using GDI PrintWindow.""" + if not _WIN32_AVAILABLE or not _PIL_OK: + return None + hwnd_dc = None + mfc_dc = None + save_dc = None + save_bmp = None + try: + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + width = right - left + height = bottom - top + if width <= 0 or height <= 0: + return None + + hwnd_dc = win32gui.GetWindowDC(hwnd) + mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc) + save_dc = mfc_dc.CreateCompatibleDC() + save_bmp = win32ui.CreateBitmap() + save_bmp.CreateCompatibleBitmap(mfc_dc, width, height) + save_dc.SelectObject(save_bmp) + + # PW_RENDERFULLCONTENT = 2 (works for hardware-accelerated windows) + import ctypes + ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2) + + bmp_str = save_bmp.GetBitmapBits(True) + img = PILImage.frombuffer("RGB", (width, height), bmp_str, "raw", "BGRX", 0, 1) + + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + except Exception as exc: + log.warning("screenshot_failed: %s", exc) + return None + finally: + try: + if save_bmp is not None: + win32gui.DeleteObject(save_bmp.GetHandle()) + if save_dc is not None: + save_dc.DeleteDC() + if mfc_dc is not None: + mfc_dc.DeleteDC() + if hwnd_dc is not None: + win32gui.ReleaseDC(hwnd, hwnd_dc) + except Exception as exc: + log.debug("GDI resource cleanup failed: %s", exc) + + +# --------------------------------------------------------------------------- +# ComBackend +# --------------------------------------------------------------------------- + + +class ComBackend(AutoCADBackend): + """Live AutoCAD control via COM API.""" + + def __init__(self): + self._executor: ThreadPoolExecutor | None = None + self._batch_mode = False + self._connected = False + self._transaction_active = False + + @property + def name(self) -> str: + return "com" + + @property + def is_connected(self) -> bool: + return self._connected + + async def connect(self) -> None: + if not _COM_IMPORTS_OK: + raise RuntimeError( + "pywin32 not available. Install with: pip install pywin32" + ) + # Lazy connection: just set up the executor. AutoCAD is connected + # on the first actual tool call so the server starts even if AutoCAD + # is not open yet — it will be found as soon as the user opens it. + self._executor = ThreadPoolExecutor(max_workers=1, initializer=_com_init) + self._connected = True + log.info("COM backend ready (will connect to AutoCAD on first tool call)") + + async def disconnect(self) -> None: + if self._executor: + self._executor.shutdown(wait=False) + self._executor = None + self._connected = False + + def _sync_test_connection(self): + """Verify AutoCAD is reachable (runs in COM thread).""" + app = _acad_app() + _ = app.Version # raises if disconnected + + async def _run(self, func, *args, **kwargs): + """Run a callable in the COM executor thread, with a hard timeout. + + Without the timeout, an unresponsive AutoCAD (modal dialog, long Regen, + crashed COM bridge) would block the single-thread STA executor forever + and freeze every subsequent tool call. + """ + if self._executor is None: + raise RuntimeError("ComBackend not connected. Call connect() first.") + loop = asyncio.get_running_loop() + timeout = config.settings.com_call_timeout + future = loop.run_in_executor( + self._executor, lambda: func(*args, **kwargs) + ) + try: + if timeout > 0: + return await asyncio.wait_for(future, timeout=timeout) + return await future + except asyncio.TimeoutError as e: + log.error("COM call timed out after %.1fs", timeout) + raise RuntimeError( + f"AutoCAD did not respond within {timeout:.0f}s. The application " + "may be showing a modal dialog or hung. Increase COM_CALL_TIMEOUT " + "if a long operation is expected." + ) from e + except pywintypes.com_error as e: + hr = e.args[0] if e.args else 0 + if hr in (-2147221246, -2147221005, -2147417842): + _COM_STATE.pop("app", None) + self._connected = False + raise RuntimeError( + f"AutoCAD COM error ({hr:#010x}): " + f"{e.args[1] if len(e.args) > 1 else e}" + ) from e + + # ── drawing management ──────────────────────────────────────────────────── + + async def drawing_info(self) -> DrawingInfo: + def _sync(): + doc = _acad_doc() + app = _acad_app() + mspace = _msp() + + try: + ext_min = doc.Database.Extmin + ext_max = doc.Database.Extmax + emin = (ext_min[0], ext_min[1]) + emax = (ext_max[0], ext_max[1]) + except Exception as exc: + log.debug("Database Extmin/Extmax read failed: %s", exc) + emin = (0.0, 0.0) + emax = (0.0, 0.0) + + entity_count = mspace.Count + layer_count = doc.Layers.Count + block_count = doc.Blocks.Count + + unit_map = {0: "Unitless", 1: "Inches", 2: "Feet", 4: "mm", 5: "cm", 6: "m"} + try: + units = unit_map.get(int(app.GetVariable("INSUNITS")), "Unknown") + except Exception as exc: + log.debug("INSUNITS variable read failed: %s", exc) + units = "Unknown" + + return DrawingInfo( + name=doc.Name, + full_path=doc.FullName, + saved=bool(doc.Saved), + entity_count=entity_count, + layer_count=layer_count, + block_count=block_count, + extents_min=emin, + extents_max=emax, + units=units, + version=app.Version, + backend="com", + ) + return await self._run(_sync) + + async def drawing_new(self, template: str | None = None) -> dict: + def _sync(): + app = _acad_app() + if template: + doc = app.Documents.Add(template) + else: + doc = app.Documents.Add() + return {"ok": True, "name": doc.Name} + return await self._run(_sync) + + async def drawing_open(self, path: str) -> dict: + def _sync(): + app = _acad_app() + doc = app.Documents.Open(path) + return {"ok": True, "name": doc.Name, "path": doc.FullName} + return await self._run(_sync) + + async def drawing_save(self, path: str | None = None) -> dict: + def _sync(): + doc = _acad_doc() + if path: + doc.SaveAs(path) + else: + doc.Save() + return {"ok": True, "path": doc.FullName} + return await self._run(_sync) + + async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: + def _sync(): + doc = _acad_doc() + # AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010 + fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5} + acad_fmt = fmt_map.get(fmt.lower(), 12) + doc.SaveAs(path, acad_fmt) + return {"ok": True, "path": path, "format": fmt} + return await self._run(_sync) + + async def drawing_export_dxf(self, path: str) -> dict: + return await self.drawing_save_as(path, "dxf") + + async def drawing_export_pdf(self, path: str) -> dict: + def _sync(): + doc = _acad_doc() + plot = doc.Plot + plot.PlotToFile(path, "DWG To PDF.pc3") + return {"ok": True, "path": path} + return await self._run(_sync) + + async def drawing_purge(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.PurgeAll() + return {"ok": True, "message": "Drawing purged"} + return await self._run(_sync) + + async def drawing_audit(self) -> dict: + def _sync(): + doc = _acad_doc() + app = _acad_app() + app.SetVariable("AUDITCTL", 1) + doc.SendCommand("_AUDIT Y\n") + return {"ok": True, "message": "Audit completed"} + return await self._run(_sync) + + async def drawing_close(self, save: bool = True) -> dict: + def _sync(): + doc = _acad_doc() + doc.Close(save) + return {"ok": True} + return await self._run(_sync) + + async def drawing_undo(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.SendCommand("_UNDO 1\n") + return {"ok": True, "message": "Undo applied"} + return await self._run(_sync) + + async def drawing_redo(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.SendCommand("_REDO\n") + return {"ok": True, "message": "Redo applied"} + return await self._run(_sync) + + # ── entity creation ─────────────────────────────────────────────────────── + + async def entity_create_line( + self, x1, y1, x2, y2, z1=0.0, z2=0.0, + layer=None, color=None, linetype=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + line = mspace.AddLine(_apoint(x1, y1, z1), _apoint(x2, y2, z2)) + _apply_entity_attrs(line, layer, color, linetype) + _regen() + return _entity_info(line) + return await self._run(_sync) + + async def entity_create_circle( + self, cx, cy, radius, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + circle = mspace.AddCircle(_apoint(cx, cy), float(radius)) + _apply_entity_attrs(circle, layer, color, None) + _regen() + return _entity_info(circle) + return await self._run(_sync) + + async def entity_create_arc( + self, cx, cy, radius, start_angle, end_angle, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + arc = mspace.AddArc( + _apoint(cx, cy), float(radius), + deg2rad(start_angle), deg2rad(end_angle), + ) + _apply_entity_attrs(arc, layer, color, None) + _regen() + return _entity_info(arc) + return await self._run(_sync) + + async def entity_create_polyline( + self, points, closed=False, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + flat = [] + for pt in points: + flat.extend([float(pt[0]), float(pt[1])]) + pline = mspace.AddLightWeightPolyline(_av(flat)) + pline.Closed = closed + _apply_entity_attrs(pline, layer, color, None) + _regen() + return _entity_info(pline) + return await self._run(_sync) + + async def entity_create_text( + self, text, x, y, height=2.5, rotation=0.0, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + txt = mspace.AddText(text, _apoint(x, y), float(height)) + txt.Rotation = deg2rad(rotation) + _apply_entity_attrs(txt, layer, color, None) + _regen() + return _entity_info(txt) + return await self._run(_sync) + + async def entity_create_mtext( + self, text, x, y, width=100.0, height=2.5, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + mt = mspace.AddMText(_apoint(x, y), float(width), text) + mt.Height = float(height) + _apply_entity_attrs(mt, layer, color, None) + _regen() + return _entity_info(mt) + return await self._run(_sync) + + async def entity_create_hatch( + self, pattern, boundary_points, scale=1.0, angle=0.0, + layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + # acPatternTypePreDefined = 0 + hatch = mspace.AddHatch(0, pattern, True) + hatch.PatternScale = float(scale) + hatch.PatternAngle = deg2rad(angle) + # Build outer loop as a temporary lwpolyline + flat = [] + for pt in boundary_points: + flat.extend([float(pt[0]), float(pt[1])]) + bnd_pline = mspace.AddLightWeightPolyline(_av(flat)) + bnd_pline.Closed = True + outer = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, [bnd_pline] + ) + hatch.AppendOuterLoop(outer) + hatch.Evaluate() + bnd_pline.Delete() + _apply_entity_attrs(hatch, layer, color, None) + _regen() + return _entity_info(hatch) + return await self._run(_sync) + + async def entity_create_spline( + self, fit_points, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + flat = [] + for pt in fit_points: + flat.extend([float(pt[0]), float(pt[1]), 0.0]) + sp = mspace.AddSpline( + _av(flat), + _apoint(0, 1), # start tangent + _apoint(0, 1), # end tangent + ) + _apply_entity_attrs(sp, layer, color, None) + _regen() + return _entity_info(sp) + return await self._run(_sync) + + async def entity_create_ellipse( + self, cx, cy, major_x, major_y, ratio=0.5, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + ellipse = mspace.AddEllipse( + _apoint(cx, cy), + _apoint(major_x, major_y), # major axis vector + float(ratio), # ratio of minor to major axis + ) + _apply_entity_attrs(ellipse, layer, color, None) + _regen() + return _entity_info(ellipse) + return await self._run(_sync) + + async def entity_create_point( + self, x, y, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + pt = mspace.AddPoint(_apoint(x, y)) + _apply_entity_attrs(pt, layer, color, None) + _regen() + return _entity_info(pt) + return await self._run(_sync) + + async def entity_create_block_ref( + self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + ref = mspace.InsertBlock( + _apoint(x, y), name, + float(scale_x), float(scale_y), 1.0, + deg2rad(rotation), + ) + if layer: + ref.Layer = layer + _regen() + return _entity_info(ref) + return await self._run(_sync) + + # ── dimensions ──────────────────────────────────────────────────────────── + + async def dimension_linear( + self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + dim = mspace.AddDimLinear( + _apoint(x1, y1), _apoint(x2, y2), + _apoint(dim_x, dim_y), deg2rad(rotation), + ) + if layer: + dim.Layer = layer + _regen() + return _entity_info(dim) + return await self._run(_sync) + + async def dimension_aligned( + self, x1, y1, x2, y2, dim_x, dim_y, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + dim = mspace.AddDimAligned( + _apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y) + ) + if layer: + dim.Layer = layer + _regen() + return _entity_info(dim) + return await self._run(_sync) + + async def dimension_angular( + self, vx, vy, x1, y1, x2, y2, tx, ty, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + dim = mspace.AddDimAngular( + _apoint(vx, vy), + _apoint(x1, y1), _apoint(x2, y2), + _apoint(tx, ty), + ) + if layer: + dim.Layer = layer + _regen() + return _entity_info(dim) + return await self._run(_sync) + + async def dimension_radius( + self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + dim = mspace.AddDimRadial( + _apoint(cx, cy), _apoint(chord_x, chord_y), float(leader_length) + ) + if layer: + dim.Layer = layer + _regen() + return _entity_info(dim) + return await self._run(_sync) + + async def dimension_diameter( + self, x1, y1, x2, y2, leader_length=10.0, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + dim = mspace.AddDimDiametric( + _apoint(x1, y1), _apoint(x2, y2), float(leader_length) + ) + if layer: + dim.Layer = layer + _regen() + return _entity_info(dim) + return await self._run(_sync) + + # ── entity modification ─────────────────────────────────────────────────── + + async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.Move(_apoint(0, 0, 0), _apoint(dx, dy, dz)) + return {"ok": True, "handle": handle} + return await self._run(_sync) + + async def entity_copy(self, handle, dx, dy, dz=0.0) -> EntityInfo: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + copy = ent.Copy() + copy.Move(_apoint(0, 0, 0), _apoint(dx, dy, dz)) + return _entity_info(copy) + return await self._run(_sync) + + async def entity_rotate(self, handle, base_x, base_y, angle_deg) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.Rotate(_apoint(base_x, base_y), deg2rad(angle_deg)) + return {"ok": True, "handle": handle} + return await self._run(_sync) + + async def entity_scale(self, handle, base_x, base_y, factor) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.ScaleEntity(_apoint(base_x, base_y), float(factor)) + return {"ok": True, "handle": handle} + return await self._run(_sync) + + async def entity_mirror( + self, handle, x1, y1, x2, y2, delete_original=False, + ) -> EntityInfo: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + mirrored = ent.Mirror(_apoint(x1, y1), _apoint(x2, y2)) + if delete_original: + ent.Delete() + return _entity_info(mirrored) + return await self._run(_sync) + + async def entity_offset( + self, handle, distance, side_x=None, side_y=None, + ) -> EntityInfo: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + offset_ent = ent.Offset(float(distance)) + # Offset returns a variant array of entities + try: + first = offset_ent[0] + except Exception as exc: + log.debug("Offset result indexing failed, using raw result: %s", exc) + first = offset_ent + return _entity_info(first) + return await self._run(_sync) + + async def entity_delete(self, handle) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.Delete() + return {"ok": True, "deleted_handle": handle} + return await self._run(_sync) + + async def entity_array_rectangular( + self, handle, rows, cols, row_spacing, col_spacing, + ) -> list[EntityInfo]: + def _sync(): + self._batch_mode = True + _COM_STATE["batch_mode"] = True + try: + doc = _acad_doc() + ent = doc.HandleToObject(handle) + result = ent.ArrayRectangular( + int(rows), int(cols), 1, # numLevels=1 + float(row_spacing), float(col_spacing), 0.0, + ) + entities = [] + try: + for e in result: + entities.append(_entity_info(e)) + except Exception as exc: + log.debug("ArrayRectangular iteration failed, using single result: %s", exc) + entities.append(_entity_info(result)) + return entities + finally: + self._batch_mode = False + _COM_STATE["batch_mode"] = False + _regen() + return await self._run(_sync) + + async def entity_array_polar( + self, handle, count, fill_angle, center_x, center_y, + ) -> list[EntityInfo]: + def _sync(): + self._batch_mode = True + _COM_STATE["batch_mode"] = True + try: + doc = _acad_doc() + ent = doc.HandleToObject(handle) + result = ent.ArrayPolar( + int(count), deg2rad(fill_angle), _apoint(center_x, center_y) + ) + entities = [] + try: + for e in result: + entities.append(_entity_info(e)) + except Exception as exc: + log.debug("ArrayPolar iteration failed, using single result: %s", exc) + entities.append(_entity_info(result)) + return entities + finally: + self._batch_mode = False + _COM_STATE["batch_mode"] = False + _regen() + return await self._run(_sync) + + # ── entity query / properties ────────────────────────────────────────────── + + async def entity_get(self, handle) -> EntityInfo: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + return _entity_info(ent) + return await self._run(_sync) + + async def entity_set_properties( + self, handle, layer=None, color=None, linetype=None, + lineweight=None, visible=None, + ) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + if layer is not None: + ent.Layer = layer + if color is not None: + ent.Color = int(color) + if linetype is not None: + ent.Linetype = linetype + if lineweight is not None: + ent.LineWeight = int(lineweight) + if visible is not None: + ent.Visible = bool(visible) + return {"ok": True, "handle": handle} + return await self._run(_sync) + + async def entity_list( + self, type_filter=None, layer_filter=None, limit=200, offset=0, + ) -> list[EntityInfo]: + def _sync(): + mspace = _msp() + results = [] + total = mspace.Count + collected = 0 + skipped = 0 + for i in range(total): + try: + ent = mspace.Item(i) + ent_type = ent.ObjectName.replace("AcDb", "").upper() + ent_layer = ent.Layer + + if type_filter and type_filter.upper() != ent_type: + continue + if layer_filter and layer_filter.lower() != ent_layer.lower(): + continue + + if skipped < offset: + skipped += 1 + continue + + results.append(_entity_info(ent)) + collected += 1 + if collected >= limit: + break + except Exception as exc: + log.debug("entity_list: skip entity at index %d: %s", i, exc) + continue + return results + return await self._run(_sync) + + # ── layer management ────────────────────────────────────────────────────── + + async def layer_list(self) -> list[LayerInfo]: + def _sync(): + doc = _acad_doc() + current = doc.ActiveLayer.Name + layers = [] + for i in range(doc.Layers.Count): + lyr = doc.Layers.Item(i) + layers.append(_layer_info(lyr, current)) + return layers + return await self._run(_sync) + + async def layer_create( + self, name, color=7, linetype="Continuous", lineweight=-3, + ) -> LayerInfo: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Add(name) + lyr.Color = int(color) + try: + lyr.Linetype = linetype + except Exception as exc: + log.warning("Failed to set linetype '%s' on layer '%s': %s", linetype, name, exc) + lyr.LineWeight = int(lineweight) + return _layer_info(lyr, doc.ActiveLayer.Name) + return await self._run(_sync) + + async def layer_delete(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.Delete() + return {"ok": True, "deleted": name} + return await self._run(_sync) + + async def layer_set_current(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + doc.ActiveLayer = lyr + return {"ok": True, "current_layer": name} + return await self._run(_sync) + + async def layer_modify( + self, name, color=None, linetype=None, lineweight=None, + ) -> LayerInfo: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + if color is not None: + lyr.Color = int(color) + if linetype is not None: + lyr.Linetype = linetype + if lineweight is not None: + lyr.LineWeight = int(lineweight) + return _layer_info(lyr, doc.ActiveLayer.Name) + return await self._run(_sync) + + async def layer_freeze(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.Freeze = True + return {"ok": True, "layer": name, "frozen": True} + return await self._run(_sync) + + async def layer_thaw(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.Freeze = False + return {"ok": True, "layer": name, "frozen": False} + return await self._run(_sync) + + async def layer_lock(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.Lock = True + return {"ok": True, "layer": name, "locked": True} + return await self._run(_sync) + + async def layer_unlock(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.Lock = False + return {"ok": True, "layer": name, "locked": False} + return await self._run(_sync) + + async def layer_hide(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.LayerOn = False + return {"ok": True, "layer": name, "visible": False} + return await self._run(_sync) + + async def layer_show(self, name) -> dict: + def _sync(): + doc = _acad_doc() + lyr = doc.Layers.Item(name) + lyr.LayerOn = True + return {"ok": True, "layer": name, "visible": True} + return await self._run(_sync) + + # ── block operations ────────────────────────────────────────────────────── + + async def block_list(self) -> list[BlockInfo]: + def _sync(): + doc = _acad_doc() + blocks = [] + for i in range(doc.Blocks.Count): + blk = doc.Blocks.Item(i) + if blk.Name.startswith("*"): # skip *Model_Space, *Paper_Space, etc. + continue + attr_count = sum( + 1 for j in range(blk.Count) + if blk.Item(j).ObjectName == "AcDbAttributeDefinition" + ) + blocks.append(BlockInfo( + name=blk.Name, + origin=(blk.Origin[0], blk.Origin[1]), + attribute_count=attr_count, + entity_count=blk.Count, + is_xref=bool(blk.IsXRef), + )) + return blocks + return await self._run(_sync) + + async def block_insert( + self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, + attributes=None, layer=None, + ) -> EntityInfo: + def _sync(): + mspace = _msp() + ref = mspace.InsertBlock( + _apoint(x, y), name, + float(scale_x), float(scale_y), 1.0, + deg2rad(rotation), + ) + if layer: + ref.Layer = layer + if attributes: + try: + attrs = ref.GetAttributes() + for attr in attrs: + tag = attr.TagString + if tag in attributes: + attr.TextString = str(attributes[tag]) + except Exception as exc: + log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc) + return _entity_info(ref) + return await self._run(_sync) + + async def block_explode(self, handle) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.Explode() + return {"ok": True, "exploded_handle": handle} + return await self._run(_sync) + + async def block_get_attributes(self, handle) -> dict: + def _sync(): + doc = _acad_doc() + ref = doc.HandleToObject(handle) + attrs = ref.GetAttributes() + result = {} + for attr in attrs: + result[attr.TagString] = attr.TextString + return result + return await self._run(_sync) + + async def block_set_attributes(self, handle, attributes) -> dict: + def _sync(): + doc = _acad_doc() + ref = doc.HandleToObject(handle) + attrs = ref.GetAttributes() + updated = [] + for attr in attrs: + tag = attr.TagString + if tag in attributes: + attr.TextString = str(attributes[tag]) + updated.append(tag) + return {"ok": True, "updated_tags": updated} + return await self._run(_sync) + + async def block_create_from_entities( + self, name, handles, base_x=0.0, base_y=0.0, + ) -> dict: + return { + "ok": False, + "error": "block_create_from_entities not supported in COM backend. " + "Use system_run_command with _BLOCK instead.", + } + + # ── analysis / query ────────────────────────────────────────────────────── + + async def analysis_stats(self) -> dict: + def _sync(): + mspace = _msp() + type_counts: dict[str, int] = {} + layer_counts: dict[str, int] = {} + for i in range(mspace.Count): + try: + ent = mspace.Item(i) + t = ent.ObjectName.replace("AcDb", "") + lyr = ent.Layer + type_counts[t] = type_counts.get(t, 0) + 1 + layer_counts[lyr] = layer_counts.get(lyr, 0) + 1 + except Exception as exc: + log.debug("analysis_stats: skip entity at index %d: %s", i, exc) + continue + return { + "total_entities": mspace.Count, + "by_type": dict(sorted(type_counts.items(), key=lambda x: -x[1])), + "by_layer": dict(sorted(layer_counts.items(), key=lambda x: -x[1])), + } + return await self._run(_sync) + + async def analysis_entities_in_region( + self, x1, y1, x2, y2, + ) -> list[EntityInfo]: + def _sync(): + doc = _acad_doc() + ss_name = f"_REGION_{uuid.uuid4().hex[:8]}" + ss = doc.SelectionSets.Add(ss_name) + try: + ss.SelectCrossing(_apoint(x1, y1), _apoint(x2, y2)) + results = [] + for i in range(ss.Count): + try: + results.append(_entity_info(ss.Item(i))) + except Exception as exc: + log.debug("analysis_entities_in_region: skip selection item %d: %s", i, exc) + continue + return results + finally: + try: + ss.Delete() + except Exception as exc: + log.debug("SelectionSet cleanup failed: %s", exc) + return await self._run(_sync) + + async def analysis_measure_distance(self, x1, y1, x2, y2) -> float: + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + async def analysis_measure_area(self, points) -> float: + return shoelace_area(points) + + async def analysis_bounding_box(self) -> dict: + def _sync(): + doc = _acad_doc() + try: + emin = doc.Database.Extmin + emax = doc.Database.Extmax + return { + "min": [emin[0], emin[1]], + "max": [emax[0], emax[1]], + "width": emax[0] - emin[0], + "height": emax[1] - emin[1], + } + except Exception as exc: + log.debug("analysis_bounding_box: Database Extmin/Extmax read failed: %s", exc) + return {"error": str(exc)} + return await self._run(_sync) + + async def analysis_select_by_layer(self, layer_name) -> list[EntityInfo]: + def _sync(): + doc = _acad_doc() + ss_name = f"_BYLAYER_{uuid.uuid4().hex[:8]}" + ss = doc.SelectionSets.Add(ss_name) + try: + ft = _ai([8]) # group code 8 = layer + fv = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, [layer_name] + ) + ss.SelectAll(ft, fv) + return [_entity_info(ss.Item(i)) for i in range(ss.Count)] + finally: + try: + ss.Delete() + except Exception as exc: + log.debug("SelectionSet cleanup failed: %s", exc) + return await self._run(_sync) + + async def analysis_select_by_type(self, entity_type) -> list[EntityInfo]: + def _sync(): + _acad_doc() + mspace = _msp() + results = [] + et_upper = entity_type.upper() + for i in range(mspace.Count): + try: + ent = mspace.Item(i) + if et_upper == ent.ObjectName.replace("AcDb", "").upper(): + results.append(_entity_info(ent)) + except Exception as exc: + log.debug("analysis_select_by_type: skip entity at index %d: %s", i, exc) + continue + return results + return await self._run(_sync) + + # ── view / screenshot ────────────────────────────────────────────────────── + + async def view_zoom_extents(self) -> dict: + def _sync(): + app = _acad_app() + app.ZoomExtents() + _acad_doc().Regen(0) + return {"ok": True} + return await self._run(_sync) + + async def view_zoom_window(self, x1, y1, x2, y2) -> dict: + def _sync(): + app = _acad_app() + app.ZoomWindow(_apoint(x1, y1), _apoint(x2, y2)) + return {"ok": True} + return await self._run(_sync) + + async def view_screenshot(self) -> bytes | None: + def _sync(): + hwnd = _find_autocad_hwnd() + if hwnd is None: + return None + return _capture_window(hwnd) + return await self._run(_sync) + + # ── transactions ────────────────────────────────────────────────────────── + + async def transaction_begin(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.StartUndoMark() + return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"} + result = await self._run(_sync) + self._transaction_active = True + return result + + async def transaction_commit(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.EndUndoMark() + return {"ok": True, "message": "Transaction committed"} + result = await self._run(_sync) + self._transaction_active = False + return result + + async def transaction_rollback(self) -> dict: + def _sync(): + doc = _acad_doc() + doc.EndUndoMark() + doc.SendCommand("_UNDO B\n") # Back to last undo mark + return {"ok": True, "message": "Transaction rolled back"} + result = await self._run(_sync) + self._transaction_active = False + return result + + # ── system ────────────────────────────────────────────────────────────── + + async def system_status(self) -> dict: + tx_active = self._transaction_active + def _sync(): + try: + app = _acad_app() + version = app.Version + doc_count = app.Documents.Count + active_doc = app.ActiveDocument.Name if doc_count > 0 else None + return { + "backend": "com", + "connected": True, + "autocad_version": version, + "open_documents": doc_count, + "active_document": active_doc, + "transaction_active": tx_active, + "capabilities": [ + "live_control", "screenshot", "transactions", + "com_api", "lisp_execution", "all_entity_types", + ], + } + except Exception as exc: + log.debug("system_status: _acad_app check failed: %s", exc) + return {"backend": "com", "connected": False, "error": str(exc)} + return await self._run(_sync) + + async def system_get_variable(self, name) -> Any: + def _sync(): + app = _acad_app() + return app.GetVariable(name) + return await self._run(_sync) + + async def system_set_variable(self, name, value) -> dict: + def _sync(): + app = _acad_app() + app.SetVariable(name, value) + return {"ok": True, "variable": name, "value": value} + return await self._run(_sync) + + async def system_run_command(self, command) -> dict: + def _sync(): + doc = _acad_doc() + cmd = command if command.endswith("\n") else command + "\n" + doc.SendCommand(cmd) + return {"ok": True, "command": command} + return await self._run(_sync) + + async def system_run_lisp(self, expression) -> dict: + def _sync(): + doc = _acad_doc() + result = doc.SendCommand(f"(progn {expression})\n") + return {"ok": True, "expression": expression, "result": str(result) if result else "nil"} + return await self._run(_sync) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py new file mode 100644 index 0000000..0ae7cfb --- /dev/null +++ b/backends/ezdxf_backend.py @@ -0,0 +1,1425 @@ +"""ezdxf backend – headless DXF/DWG file operations. + +Works without AutoCAD installed. Ideal for file generation and analysis. +All operations are synchronous but wrapped in asyncio.to_thread for +non-blocking use in the FastMCP async context. +""" + +from __future__ import annotations + +import asyncio +import io +import logging +import math +import os +import tempfile +from pathlib import Path +from typing import Any + +import config + +from .base import ( + AutoCADBackend, + BlockInfo, + DrawingInfo, + EntityInfo, + LayerInfo, + shoelace_area, +) + +log = logging.getLogger(__name__) + +try: + import ezdxf + from ezdxf import colors, units # noqa: F401 + from ezdxf.enums import TextEntityAlignment # noqa: F401 + from ezdxf.math import BSpline, Vec2, Vec3 # noqa: F401 + _EZDXF_OK = True +except ImportError: + _EZDXF_OK = False + log.warning("ezdxf not installed. ezdxf backend unavailable.") + +try: + from ezdxf import bbox as ezdxf_bbox + _BBOX_OK = True +except ImportError: + _BBOX_OK = False + + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + +_ACI_NAMES = { + 1: "Red", 2: "Yellow", 3: "Green", 4: "Cyan", + 5: "Blue", 6: "Magenta", 7: "White", 256: "ByLayer", 0: "ByBlock", +} + + +def _entity_info_dxf(ent) -> EntityInfo: + """Convert an ezdxf entity to EntityInfo.""" + handle = ent.dxf.get("handle", "?") + ent_type = ent.dxftype() + layer = ent.dxf.get("layer", "0") + color = ent.dxf.get("color", 256) + linetype = ent.dxf.get("linetype", "ByLayer") + visible = ent.dxf.get("invisible", False) is False + + props: dict = {} + def _v2(v): + """Convert Vec3/tuple to [x, y] list.""" + return [float(v.x), float(v.y)] if hasattr(v, 'x') else [float(v[0]), float(v[1])] + + try: + if ent_type == "LINE": + props["start"] = _v2(ent.dxf.start) + props["end"] = _v2(ent.dxf.end) + props["length"] = ent.dxf.start.distance(ent.dxf.end) + elif ent_type == "CIRCLE": + props["center"] = _v2(ent.dxf.center) + props["radius"] = ent.dxf.radius + elif ent_type == "ARC": + props["center"] = _v2(ent.dxf.center) + props["radius"] = ent.dxf.radius + props["start_angle"] = ent.dxf.start_angle + props["end_angle"] = ent.dxf.end_angle + elif ent_type == "LWPOLYLINE": + props["points"] = [[float(pt[0]), float(pt[1])] for pt in ent.get_points()] + props["closed"] = ent.closed + props["length"] = ent.length() + elif ent_type == "TEXT": + props["text"] = ent.dxf.text + props["insertion"] = _v2(ent.dxf.insert) + props["height"] = ent.dxf.height + props["rotation"] = ent.dxf.get("rotation", 0.0) + elif ent_type == "MTEXT": + props["text"] = ent.text + props["insertion"] = _v2(ent.dxf.insert) + props["char_height"] = ent.dxf.char_height + elif ent_type == "INSERT": + props["block_name"] = ent.dxf.name + props["insertion"] = _v2(ent.dxf.insert) + props["x_scale"] = ent.dxf.xscale if ent.dxf.hasattr("xscale") else 1.0 + props["y_scale"] = ent.dxf.yscale if ent.dxf.hasattr("yscale") else 1.0 + props["rotation_deg"] = ent.dxf.get("rotation", 0.0) + elif ent_type == "ELLIPSE": + props["center"] = _v2(ent.dxf.center) + props["major_axis"] = _v2(ent.dxf.major_axis) + props["ratio"] = ent.dxf.ratio + elif ent_type == "SPLINE": + if ent.dxf.hasattr("fit_points"): + props["fit_point_count"] = len(list(ent.fit_points)) + props["degree"] = ent.dxf.degree + elif ent_type in ("DIMLINEAR", "DIMALIGNED", "DIMANGULAR", + "DIMRADIUS", "DIMDIAMETER"): + props["dim_type"] = ent_type + except Exception as exc: + log.debug("extracting entity properties for %s: %s", ent_type, exc) + + return EntityInfo( + handle=str(handle), + type=ent_type, + layer=layer, + color=color, + linetype=linetype, + visible=visible, + properties=props, + ) + + +def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo: + lw = layer_obj.dxf.get("lineweight", -3) + return LayerInfo( + name=layer_obj.dxf.name, + color=abs(layer_obj.dxf.get("color", 7)), + linetype=layer_obj.dxf.get("linetype", "Continuous"), + lineweight=float(lw), + is_on=not layer_obj.is_off(), + is_frozen=layer_obj.is_frozen(), + is_locked=layer_obj.is_locked(), + is_current=(layer_obj.dxf.name == current_name), + ) + + +# --------------------------------------------------------------------------- +# EzdxfBackend +# --------------------------------------------------------------------------- + + +class EzdxfBackend(AutoCADBackend): + """File-based ezdxf backend – no live AutoCAD needed.""" + + def __init__(self): + if not _EZDXF_OK: + raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf") + self._doc: Any = None # ezdxf Drawing object + self._doc_path: str | None = None + self._dirty: bool = False + self._current_layer: str = "0" + self._undo_stack: list[Path] = [] # temp-file paths to DXF snapshots + self._connected = False + self._lock = asyncio.Lock() + + @property + def name(self) -> str: + return "ezdxf" + + @property + def is_connected(self) -> bool: + return self._connected + + async def connect(self) -> None: + self._connected = True + log.info("ezdxf backend ready") + + async def disconnect(self) -> None: + self._cleanup_undo_stack() + self._connected = False + + def _cleanup_undo_stack(self): + while self._undo_stack: + p = self._undo_stack.pop() + try: + p.unlink() + except OSError: + pass + + def _require_doc(self): + if self._doc is None: + raise RuntimeError( + "No document open. Call drawing_new() or drawing_open() first." + ) + return self._doc + + def _msp(self): + return self._require_doc().modelspace() + + async def _async(self, func, *args, **kwargs): + async with self._lock: + return await asyncio.to_thread(func, *args, **kwargs) + + # ── drawing management ──────────────────────────────────────────────────── + + async def drawing_info(self) -> DrawingInfo: + def _sync(): + doc = self._require_doc() + msp = self._msp() + entities = list(msp) + layers = list(doc.layers) + + if _BBOX_OK: + try: + bb = ezdxf_bbox.extents(msp) + emin = (bb.extmin.x, bb.extmin.y) if bb else (0.0, 0.0) + emax = (bb.extmax.x, bb.extmax.y) if bb else (0.0, 0.0) + except Exception as exc: + log.debug("computing drawing extents via bbox: %s", exc) + emin, emax = (0.0, 0.0), (0.0, 0.0) + else: + emin, emax = (0.0, 0.0), (0.0, 0.0) + + unit_map = {0: "Unitless", 1: "Inches", 2: "Feet", 4: "mm", 5: "cm", 6: "m"} + try: + ins_units = doc.header.get("$INSUNITS", 0) + unit_str = unit_map.get(ins_units, "Unknown") + except Exception as exc: + log.debug("reading $INSUNITS from header: %s", exc) + unit_str = "Unknown" + + return DrawingInfo( + name=Path(self._doc_path).name if self._doc_path else "untitled.dxf", + full_path=self._doc_path or "", + saved=not self._dirty, + entity_count=len(entities), + layer_count=len(layers), + block_count=len(list(doc.blocks)), + extents_min=emin, + extents_max=emax, + units=unit_str, + version=doc.dxfversion, + backend="ezdxf", + ) + return await self._async(_sync) + + async def drawing_new(self, template: str | None = None) -> dict: + def _sync(): + if template and Path(template).exists(): + self._doc = ezdxf.readfile(template) + else: + self._doc = ezdxf.new(dxfversion="R2010") + self._doc_path = None + self._dirty = False + self._current_layer = "0" + return {"ok": True, "name": "untitled.dxf"} + return await self._async(_sync) + + async def drawing_open(self, path: str) -> dict: + def _sync(): + max_bytes = config.settings.max_dxf_bytes + if max_bytes > 0: + try: + size = os.path.getsize(path) + except OSError as exc: + raise RuntimeError(f"Cannot stat DXF file: {exc}") from exc + if size > max_bytes: + raise RuntimeError( + f"DXF file exceeds MAX_DXF_BYTES limit " + f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override." + ) + self._doc = ezdxf.readfile(path) + self._doc_path = path + self._dirty = False + try: + self._current_layer = self._doc.header.get("$CLAYER", "0") + except Exception as exc: + log.debug("reading $CLAYER from header: %s", exc) + self._current_layer = "0" + return {"ok": True, "name": Path(path).name, "path": path} + return await self._async(_sync) + + async def drawing_save(self, path: str | None = None) -> dict: + def _sync(): + doc = self._require_doc() + save_path = path or self._doc_path + if not save_path: + raise RuntimeError("No path specified and no current file path.") + doc.saveas(save_path) + self._doc_path = save_path + self._dirty = False + return {"ok": True, "path": save_path} + return await self._async(_sync) + + async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: + def _sync(): + doc = self._require_doc() + doc.saveas(path) + self._doc_path = path + self._dirty = False + return {"ok": True, "path": path, "format": fmt} + return await self._async(_sync) + + async def drawing_export_dxf(self, path: str) -> dict: + return await self.drawing_save_as(path, "dxf") + + async def drawing_export_pdf(self, path: str) -> dict: + """Export to PDF via ezdxf's matplotlib backend.""" + def _sync(): + doc = self._require_doc() + msp = self._msp() + try: + import matplotlib.pyplot as plt + from ezdxf.addons.drawing import Frontend, RenderContext + from ezdxf.addons.drawing.matplotlib import MatplotlibBackend + + fig = plt.figure() + ax = fig.add_axes([0, 0, 1, 1]) + ctx = RenderContext(doc) + out = MatplotlibBackend(ax) + Frontend(ctx, out).draw_layout(msp, finalize=True) + fig.savefig(path, dpi=150) + plt.close(fig) + return {"ok": True, "path": path} + except ImportError: + raise RuntimeError( + "PDF export requires matplotlib: pip install matplotlib" + ) from None + return await self._async(_sync) + + async def drawing_purge(self) -> dict: + def _sync(): + doc = self._require_doc() + purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0} + + used_blocks: set[str] = set() + for ent in doc.entitydb.values(): + if ent.dxftype() == "INSERT": + used_blocks.add(ent.dxf.name) + for blk in list(doc.blocks): + name = blk.name + if name.startswith("*") or name in used_blocks: + continue + try: + doc.blocks.delete_block(name, safe=True) + purged["blocks"] += 1 + except Exception: + pass + + used_layers: set[str] = {"0", "Defpoints"} + for ent in doc.entitydb.values(): + if hasattr(ent.dxf, "layer"): + used_layers.add(ent.dxf.layer) + for lyr in list(doc.layers): + name = lyr.dxf.name + if name in used_layers: + continue + try: + doc.layers.remove(name) + purged["layers"] += 1 + except Exception: + pass + + used_linetypes: set[str] = {"BYLAYER", "BYBLOCK", "Continuous"} + for lyr in doc.layers: + used_linetypes.add(lyr.dxf.linetype) + for ent in doc.entitydb.values(): + if hasattr(ent.dxf, "linetype"): + used_linetypes.add(ent.dxf.linetype) + for lt in list(doc.linetypes): + name = lt.dxf.name + if name in used_linetypes: + continue + try: + doc.linetypes.remove(name) + purged["linetypes"] += 1 + except Exception: + pass + + used_styles: set[str] = {"Standard"} + for ent in doc.entitydb.values(): + if hasattr(ent.dxf, "style"): + used_styles.add(ent.dxf.style) + for st in list(doc.styles): + name = st.dxf.name + if name in used_styles: + continue + try: + doc.styles.remove(name) + purged["text_styles"] += 1 + except Exception: + pass + + self._mark_dirty() + return {"ok": True, "purged": purged} + return await self._async(_sync) + + async def drawing_audit(self) -> dict: + def _sync(): + doc = self._require_doc() + auditor = doc.audit() + return { + "ok": True, + "errors": [str(e) for e in auditor.errors], + "error_count": len(auditor.errors), + } + return await self._async(_sync) + + async def drawing_close(self, save: bool = True) -> dict: + def _sync(): + if save and self._dirty and self._doc_path: + self._doc.saveas(self._doc_path) + self._cleanup_undo_stack() + self._doc = None + self._doc_path = None + self._dirty = False + return {"ok": True} + return await self._async(_sync) + + async def drawing_undo(self) -> dict: + if not self._undo_stack: + return {"ok": False, "error": "Nothing to undo (no undo history in ezdxf backend)"} + def _sync(): + p = self._undo_stack.pop() + try: + self._doc = ezdxf.readfile(str(p)) + self._dirty = True + finally: + try: + p.unlink() + except OSError: + pass + return {"ok": True, "message": "Undone to last saved state"} + return await self._async(_sync) + + async def drawing_redo(self) -> dict: + return {"ok": False, "error": "Redo not supported in ezdxf backend"} + + # ── internal: apply common attrs ────────────────────────────────────────── + + def _apply_attrs(self, entity, layer: str | None, color: int | None, linetype: str | None = None): + if layer is not None: + entity.dxf.layer = layer + # Ensure layer exists + doc = self._require_doc() + if layer not in doc.layers: + doc.layers.add(layer) + if color is not None: + entity.dxf.color = int(color) + if linetype is not None: + entity.dxf.linetype = linetype + + def _mark_dirty(self): + self._dirty = True + + # ── entity creation ─────────────────────────────────────────────────────── + + async def entity_create_line( + self, x1, y1, x2, y2, z1=0.0, z2=0.0, + layer=None, color=None, linetype=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_line( + (float(x1), float(y1), float(z1)), + (float(x2), float(y2), float(z2)), + ) + self._apply_attrs(ent, layer, color, linetype) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_circle( + self, cx, cy, radius, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_circle((float(cx), float(cy)), float(radius)) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_arc( + self, cx, cy, radius, start_angle, end_angle, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_arc( + (float(cx), float(cy)), float(radius), + float(start_angle), float(end_angle), + ) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_polyline( + self, points, closed=False, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + pts_2d = [(float(p[0]), float(p[1])) for p in points] + ent = msp.add_lwpolyline(pts_2d, close=closed) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_text( + self, text, x, y, height=2.5, rotation=0.0, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_text( + text, + dxfattribs={ + "height": float(height), + "rotation": float(rotation), + "insert": (float(x), float(y)), + }, + ) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_mtext( + self, text, x, y, width=100.0, height=2.5, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_mtext( + text, + dxfattribs={"char_height": float(height), "width": float(width)}, + ) + ent.dxf.insert = (float(x), float(y), 0.0) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_hatch( + self, pattern, boundary_points, scale=1.0, angle=0.0, + layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + hatch = msp.add_hatch() + hatch.set_pattern_fill(pattern, scale=float(scale), angle=float(angle)) + pts = [(float(p[0]), float(p[1])) for p in boundary_points] + hatch.paths.add_polyline_path(pts, is_closed=True) + self._apply_attrs(hatch, layer, color) + self._mark_dirty() + return _entity_info_dxf(hatch) + return await self._async(_sync) + + async def entity_create_spline( + self, fit_points, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + pts = [(float(p[0]), float(p[1]), 0.0) for p in fit_points] + ent = msp.add_spline(fit_points=pts) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_ellipse( + self, cx, cy, major_x, major_y, ratio=0.5, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_ellipse( + center=(float(cx), float(cy), 0.0), + major_axis=(float(major_x), float(major_y), 0.0), + ratio=float(ratio), + ) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_point( + self, x, y, layer=None, color=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_point((float(x), float(y))) + self._apply_attrs(ent, layer, color) + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def entity_create_block_ref( + self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + ent = msp.add_blockref( + name, + (float(x), float(y)), + dxfattribs={ + "xscale": float(scale_x), + "yscale": float(scale_y), + "rotation": float(rotation), + }, + ) + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + # ── dimensions ─────────────────────────────────────────────────────────── + + async def dimension_linear( + self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + dim = msp.add_linear_dim( + base=(float(dim_x), float(dim_y)), + p1=(float(x1), float(y1)), + p2=(float(x2), float(y2)), + angle=float(rotation), + ) + dim.render() + ent = dim.dimension + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def dimension_aligned( + self, x1, y1, x2, y2, dim_x, dim_y, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + dim = msp.add_aligned_dim( + p1=(float(x1), float(y1)), + p2=(float(x2), float(y2)), + dist=math.sqrt((float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2), + ) + dim.render() + ent = dim.dimension + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def dimension_angular( + self, vx, vy, x1, y1, x2, y2, tx, ty, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + dim = msp.add_angular_dim_2l( + center=(float(vx), float(vy)), + p1=(float(x1), float(y1)), + p2=(float(x2), float(y2)), + distance=10.0, + ) + dim.render() + ent = dim.dimension + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def dimension_radius( + self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + cxf, cyf = float(cx), float(cy) + radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2) + angle_rad = math.atan2(chord_y - cyf, chord_x - cxf) + leader = float(leader_length) + mpoint = ( + cxf + (radius + leader) * math.cos(angle_rad), + cyf + (radius + leader) * math.sin(angle_rad), + ) + dim = msp.add_radius_dim( + center=(cxf, cyf), + mpoint=mpoint, + ) + dim.render() + ent = dim.dimension + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + async def dimension_diameter( + self, x1, y1, x2, y2, leader_length=10.0, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2) + cx = (x1f + x2f) / 2 + cy = (y1f + y2f) / 2 + radius = math.sqrt((x2f - x1f) ** 2 + (y2f - y1f) ** 2) / 2 + angle_rad = math.atan2(y2f - y1f, x2f - x1f) + leader = float(leader_length) + mpoint = ( + cx + (radius + leader) * math.cos(angle_rad), + cy + (radius + leader) * math.sin(angle_rad), + ) + dim = msp.add_diameter_dim( + center=(cx, cy), + mpoint=mpoint, + ) + dim.render() + ent = dim.dimension + if layer: + ent.dxf.layer = layer + self._mark_dirty() + return _entity_info_dxf(ent) + return await self._async(_sync) + + # ── entity modification ─────────────────────────────────────────────────── + + def _get_entity(self, handle: str): + doc = self._require_doc() + ent = doc.entitydb.get(handle) + if ent is None: + raise RuntimeError(f"Entity with handle '{handle}' not found.") + return ent + + async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: + def _sync(): + ent = self._get_entity(handle) + ent.translate(float(dx), float(dy), float(dz)) + self._mark_dirty() + return {"ok": True, "handle": handle} + return await self._async(_sync) + + async def entity_copy(self, handle, dx, dy, dz=0.0) -> EntityInfo: + def _sync(): + ent = self._get_entity(handle) + copy = ent.copy() + self._msp().add_entity(copy) + copy.translate(float(dx), float(dy), float(dz)) + self._mark_dirty() + return _entity_info_dxf(copy) + return await self._async(_sync) + + async def entity_rotate(self, handle, base_x, base_y, angle_deg) -> dict: + def _sync(): + ent = self._get_entity(handle) + from ezdxf.math import Matrix44 + m = Matrix44.z_rotate(math.radians(float(angle_deg))) + # Translate to origin, rotate, translate back + bx, by = float(base_x), float(base_y) + ent.transform( + Matrix44.translate(-bx, -by, 0) + @ m + @ Matrix44.translate(bx, by, 0) + ) + self._mark_dirty() + return {"ok": True, "handle": handle} + return await self._async(_sync) + + async def entity_scale(self, handle, base_x, base_y, factor) -> dict: + def _sync(): + ent = self._get_entity(handle) + from ezdxf.math import Matrix44 + s = float(factor) + bx, by = float(base_x), float(base_y) + ent.transform( + Matrix44.translate(-bx, -by, 0) + @ Matrix44.scale(s, s, s) + @ Matrix44.translate(bx, by, 0) + ) + self._mark_dirty() + return {"ok": True, "handle": handle} + return await self._async(_sync) + + async def entity_mirror( + self, handle, x1, y1, x2, y2, delete_original=False, + ) -> EntityInfo: + def _sync(): + ent = self._get_entity(handle) + copy = ent.copy() + self._msp().add_entity(copy) + from ezdxf.math import Matrix44 + # Mirror across the line defined by (x1,y1)-(x2,y2) + dx = float(x2) - float(x1) + dy = float(y2) - float(y1) + length = math.sqrt(dx * dx + dy * dy) + if length == 0: + raise ValueError("Mirror line has zero length") + cos2 = (dx * dx - dy * dy) / (length * length) + sin2 = 2 * dx * dy / (length * length) + m = Matrix44(( + cos2, sin2, 0, 0, + sin2, -cos2, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + )) + tx, ty = float(x1), float(y1) + copy.transform( + Matrix44.translate(-tx, -ty, 0) + @ m + @ Matrix44.translate(tx, ty, 0) + ) + if delete_original: + self._msp().delete_entity(ent) + self._mark_dirty() + return _entity_info_dxf(copy) + return await self._async(_sync) + + async def entity_offset( + self, handle, distance, side_x=None, side_y=None, + ) -> EntityInfo: + def _sync(): + ent = self._get_entity(handle) + ent_type = ent.dxftype() + d = float(distance) + + if ent_type == "LINE": + # Offset a line: move parallel + start = Vec2(ent.dxf.start.x, ent.dxf.start.y) + end = Vec2(ent.dxf.end.x, ent.dxf.end.y) + direction = (end - start).normalize() + normal = Vec2(-direction.y, direction.x) * d + new_start = start + normal + new_end = end + normal + msp = self._msp() + new_ent = msp.add_line( + (new_start.x, new_start.y), + (new_end.x, new_end.y), + dxfattribs={"layer": ent.dxf.layer}, + ) + self._mark_dirty() + return _entity_info_dxf(new_ent) + + elif ent_type == "CIRCLE": + cx, cy = ent.dxf.center.x, ent.dxf.center.y + new_r = ent.dxf.radius + d + if new_r <= 0: + raise ValueError("Offset distance too large for circle") + msp = self._msp() + new_ent = msp.add_circle( + (cx, cy), new_r, + dxfattribs={"layer": ent.dxf.layer}, + ) + self._mark_dirty() + return _entity_info_dxf(new_ent) + + else: + raise RuntimeError(f"Offset not supported for {ent_type}") + return await self._async(_sync) + + async def entity_delete(self, handle) -> dict: + def _sync(): + ent = self._get_entity(handle) + self._msp().delete_entity(ent) + self._mark_dirty() + return {"ok": True, "deleted_handle": handle} + return await self._async(_sync) + + async def entity_array_rectangular( + self, handle, rows, cols, row_spacing, col_spacing, + ) -> list[EntityInfo]: + def _sync(): + ent = self._get_entity(handle) + msp = self._msp() + results = [] + for r in range(int(rows)): + for c in range(int(cols)): + if r == 0 and c == 0: + continue # skip original + copy = ent.copy() + msp.add_entity(copy) + copy.translate(c * float(col_spacing), r * float(row_spacing), 0) + results.append(_entity_info_dxf(copy)) + self._mark_dirty() + return results + return await self._async(_sync) + + async def entity_array_polar( + self, handle, count, fill_angle, center_x, center_y, + ) -> list[EntityInfo]: + def _sync(): + ent = self._get_entity(handle) + msp = self._msp() + from ezdxf.math import Matrix44 + results = [] + cx, cy = float(center_x), float(center_y) + step = math.radians(float(fill_angle)) / max(int(count) - 1, 1) + for i in range(1, int(count)): + copy = ent.copy() + msp.add_entity(copy) + angle = step * i + m = ( + Matrix44.translate(-cx, -cy, 0) + @ Matrix44.z_rotate(angle) + @ Matrix44.translate(cx, cy, 0) + ) + copy.transform(m) + results.append(_entity_info_dxf(copy)) + self._mark_dirty() + return results + return await self._async(_sync) + + # ── entity query / properties ───────────────────────────────────────────── + + async def entity_get(self, handle) -> EntityInfo: + def _sync(): + return _entity_info_dxf(self._get_entity(handle)) + return await self._async(_sync) + + async def entity_set_properties( + self, handle, layer=None, color=None, linetype=None, + lineweight=None, visible=None, + ) -> dict: + def _sync(): + ent = self._get_entity(handle) + if layer is not None: + ent.dxf.layer = layer + if color is not None: + ent.dxf.color = int(color) + if linetype is not None: + ent.dxf.linetype = linetype + if lineweight is not None: + ent.dxf.lineweight = int(lineweight) + if visible is not None: + ent.dxf.invisible = not bool(visible) + self._mark_dirty() + return {"ok": True, "handle": handle} + return await self._async(_sync) + + async def entity_list( + self, type_filter=None, layer_filter=None, limit=200, offset=0, + ) -> list[EntityInfo]: + def _sync(): + msp = self._msp() + results = [] + skipped = 0 + for ent in msp: + ent_type = ent.dxftype() + ent_layer = ent.dxf.get("layer", "0") + if type_filter and type_filter.upper() != ent_type.upper(): + continue + if layer_filter and layer_filter.lower() != ent_layer.lower(): + continue + if skipped < offset: + skipped += 1 + continue + results.append(_entity_info_dxf(ent)) + if len(results) >= limit: + break + return results + return await self._async(_sync) + + # ── layer management ────────────────────────────────────────────────────── + + async def layer_list(self) -> list[LayerInfo]: + def _sync(): + doc = self._require_doc() + return [ + _layer_info_dxf(lyr, self._current_layer) + for lyr in doc.layers + ] + return await self._async(_sync) + + async def layer_create( + self, name, color=7, linetype="Continuous", lineweight=-3, + ) -> LayerInfo: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.add( + name, + color=int(color), + linetype=linetype, + lineweight=int(lineweight), + ) + self._mark_dirty() + return _layer_info_dxf(lyr, self._current_layer) + return await self._async(_sync) + + async def layer_delete(self, name) -> dict: + def _sync(): + doc = self._require_doc() + if name in doc.layers: + doc.layers.remove(name) + self._mark_dirty() + return {"ok": True, "deleted": name} + return await self._async(_sync) + + async def layer_set_current(self, name) -> dict: + def _sync(): + doc = self._require_doc() + if name not in doc.layers: + doc.layers.add(name) + self._current_layer = name + try: + doc.header["$CLAYER"] = name + except Exception as exc: + log.debug("writing $CLAYER to header: %s", exc) + self._mark_dirty() + return {"ok": True, "current_layer": name} + return await self._async(_sync) + + async def layer_modify( + self, name, color=None, linetype=None, lineweight=None, + ) -> LayerInfo: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr is None: + raise RuntimeError(f"Layer '{name}' not found.") + if color is not None: + lyr.dxf.color = int(color) + if linetype is not None: + lyr.dxf.linetype = linetype + if lineweight is not None: + lyr.dxf.lineweight = int(lineweight) + self._mark_dirty() + return _layer_info_dxf(lyr, self._current_layer) + return await self._async(_sync) + + async def layer_freeze(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.freeze() + self._mark_dirty() + return {"ok": True, "layer": name, "frozen": True} + return await self._async(_sync) + + async def layer_thaw(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.thaw() + self._mark_dirty() + return {"ok": True, "layer": name, "frozen": False} + return await self._async(_sync) + + async def layer_lock(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.lock() + self._mark_dirty() + return {"ok": True, "layer": name, "locked": True} + return await self._async(_sync) + + async def layer_unlock(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.unlock() + self._mark_dirty() + return {"ok": True, "layer": name, "locked": False} + return await self._async(_sync) + + async def layer_hide(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.off() + self._mark_dirty() + return {"ok": True, "layer": name, "visible": False} + return await self._async(_sync) + + async def layer_show(self, name) -> dict: + def _sync(): + doc = self._require_doc() + lyr = doc.layers.get(name) + if lyr: + lyr.on() + self._mark_dirty() + return {"ok": True, "layer": name, "visible": True} + return await self._async(_sync) + + # ── block operations ────────────────────────────────────────────────────── + + async def block_list(self) -> list[BlockInfo]: + def _sync(): + doc = self._require_doc() + blocks = [] + for blk in doc.blocks: + if blk.name.startswith("*"): + continue + attr_count = sum( + 1 for e in blk if e.dxftype() == "ATTDEF" + ) + origin = blk.block.dxf.get("base_point", (0.0, 0.0, 0.0)) + is_xref = bool(blk.block.dxf.get("xref_path", "")) + blocks.append(BlockInfo( + name=blk.name, + origin=(origin[0], origin[1]), + attribute_count=attr_count, + entity_count=len(list(blk)), + is_xref=is_xref, + )) + return blocks + return await self._async(_sync) + + async def block_insert( + self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, + attributes=None, layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + attribs: dict = { + "xscale": float(scale_x), + "yscale": float(scale_y), + "rotation": float(rotation), + } + if layer: + attribs["layer"] = layer + if attributes: + ref = msp.add_auto_blockref( + name, (float(x), float(y)), attributes, dxfattribs=attribs + ) + else: + ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) + self._mark_dirty() + return _entity_info_dxf(ref) + return await self._async(_sync) + + async def block_explode(self, handle) -> dict: + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") + msp = self._msp() + # Decompose: add individual entities to modelspace + inserted = [] + for sub in ent.virtual_entities(): + sub_copy = sub.copy() + msp.add_entity(sub_copy) + inserted.append(sub_copy.dxf.handle) + msp.delete_entity(ent) + self._mark_dirty() + return {"ok": True, "inserted_handles": inserted} + return await self._async(_sync) + + async def block_get_attributes(self, handle) -> dict: + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference") + result = {} + for attrib in ent.attribs: + result[attrib.dxf.tag] = attrib.dxf.text + return result + return await self._async(_sync) + + async def block_set_attributes(self, handle, attributes) -> dict: + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference") + updated = [] + for attrib in ent.attribs: + tag = attrib.dxf.tag + if tag in attributes: + attrib.dxf.text = str(attributes[tag]) + updated.append(tag) + self._mark_dirty() + return {"ok": True, "updated_tags": updated} + return await self._async(_sync) + + async def block_create_from_entities( + self, name, handles, base_x=0.0, base_y=0.0, + ) -> dict: + def _sync(): + doc = self._require_doc() + blk = doc.blocks.new(name=name) + blk.block.dxf.base_point = (float(base_x), float(base_y), 0.0) + count = 0 + for handle in handles: + try: + ent = self._get_entity(handle) + copy = ent.copy() + blk.add_entity(copy) + count += 1 + except Exception as exc: + log.debug("adding entity %s to block %s: %s", handle, name, exc) + self._mark_dirty() + return {"ok": True, "name": name, "entity_count": count} + return await self._async(_sync) + + # ── analysis / query ────────────────────────────────────────────────────── + + async def analysis_stats(self) -> dict: + def _sync(): + msp = self._msp() + type_counts: dict[str, int] = {} + layer_counts: dict[str, int] = {} + for ent in msp: + t = ent.dxftype() + lyr = ent.dxf.get("layer", "0") + type_counts[t] = type_counts.get(t, 0) + 1 + layer_counts[lyr] = layer_counts.get(lyr, 0) + 1 + total = sum(type_counts.values()) + return { + "total_entities": total, + "by_type": dict(sorted(type_counts.items(), key=lambda x: -x[1])), + "by_layer": dict(sorted(layer_counts.items(), key=lambda x: -x[1])), + } + return await self._async(_sync) + + async def analysis_entities_in_region( + self, x1, y1, x2, y2, + ) -> list[EntityInfo]: + def _sync(): + msp = self._msp() + results = [] + mn_x, mx_x = min(float(x1), float(x2)), max(float(x1), float(x2)) + mn_y, mx_y = min(float(y1), float(y2)), max(float(y1), float(y2)) + + if _BBOX_OK: + for ent in msp: + try: + bb = ezdxf_bbox.extents([ent]) + if bb and bb.extmin.x >= mn_x and bb.extmax.x <= mx_x \ + and bb.extmin.y >= mn_y and bb.extmax.y <= mx_y: + results.append(_entity_info_dxf(ent)) + except Exception as exc: + log.debug("bbox check for entity in region: %s", exc) + continue + else: + # Fallback: check insertion points + for ent in msp: + try: + ins = None + if hasattr(ent.dxf, "insert"): + ins = ent.dxf.insert + elif hasattr(ent.dxf, "start"): + ins = ent.dxf.start + if ins and mn_x <= ins[0] <= mx_x and mn_y <= ins[1] <= mx_y: + results.append(_entity_info_dxf(ent)) + except Exception as exc: + log.debug("insertion point check for entity in region: %s", exc) + continue + return results + return await self._async(_sync) + + async def analysis_measure_distance(self, x1, y1, x2, y2) -> float: + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + async def analysis_measure_area(self, points) -> float: + return shoelace_area(points) + + async def analysis_bounding_box(self) -> dict: + def _sync(): + msp = self._msp() + if _BBOX_OK: + try: + bb = ezdxf_bbox.extents(msp) + if bb: + return { + "min": [bb.extmin.x, bb.extmin.y], + "max": [bb.extmax.x, bb.extmax.y], + "width": bb.extmax.x - bb.extmin.x, + "height": bb.extmax.y - bb.extmin.y, + } + except Exception as e: + return {"error": str(e)} + return {"error": "ezdxf bbox not available"} + return await self._async(_sync) + + async def analysis_select_by_layer(self, layer_name) -> list[EntityInfo]: + def _sync(): + msp = self._msp() + return [ + _entity_info_dxf(e) + for e in msp + if e.dxf.get("layer", "0").lower() == layer_name.lower() + ] + return await self._async(_sync) + + async def analysis_select_by_type(self, entity_type) -> list[EntityInfo]: + def _sync(): + msp = self._msp() + et = entity_type.upper() + return [ + _entity_info_dxf(e) + for e in msp + if et == e.dxftype().upper() + ] + return await self._async(_sync) + + # ── view / screenshot ────────────────────────────────────────────────────── + + async def view_zoom_extents(self) -> dict: + return {"ok": True, "message": "Zoom extents not applicable for ezdxf backend (no display)"} + + async def view_zoom_window(self, x1, y1, x2, y2) -> dict: + return {"ok": True, "message": "Zoom window not applicable for ezdxf backend (no display)"} + + async def view_screenshot(self) -> bytes | None: + """Render drawing to PNG using matplotlib.""" + def _sync(): + try: + doc = self._require_doc() + msp = self._msp() + import matplotlib.pyplot as plt + from ezdxf.addons.drawing import Frontend, RenderContext + from ezdxf.addons.drawing.matplotlib import MatplotlibBackend + + fig = plt.figure(figsize=(16, 9), dpi=100) + ax = fig.add_axes([0, 0, 1, 1]) + ctx = RenderContext(doc) + out = MatplotlibBackend(ax) + Frontend(ctx, out).draw_layout(msp, finalize=True) + + buf = io.BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", dpi=100) + plt.close(fig) + buf.seek(0) + return buf.read() + except ImportError: + log.warning("matplotlib not installed – screenshot unavailable") + return None + return await self._async(_sync) + + # ── transactions ────────────────────────────────────────────────────────── + + async def transaction_begin(self) -> dict: + def _sync(): + doc = self._require_doc() + fd, tmp_path = tempfile.mkstemp(suffix=".dxf", prefix="acad_mcp_undo_") + os.close(fd) + try: + doc.saveas(tmp_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + self._undo_stack.append(Path(tmp_path)) + max_stack = config.settings.max_undo_stack + while len(self._undo_stack) > max_stack: + old = self._undo_stack.pop(0) + try: + old.unlink() + except OSError: + pass + return {"ok": True, "message": "Transaction begun (DXF snapshot saved for rollback)"} + return await self._async(_sync) + + async def transaction_commit(self) -> dict: + if not self._undo_stack: + return {"ok": False, "error": "No active transaction"} + p = self._undo_stack.pop() + try: + p.unlink() + except OSError: + pass + return {"ok": True, "message": "Transaction committed (snapshot discarded)"} + + async def transaction_rollback(self) -> dict: + if not self._undo_stack: + return {"ok": False, "error": "No active transaction to rollback"} + def _sync(): + p = self._undo_stack.pop() + try: + self._doc = ezdxf.readfile(str(p)) + self._dirty = True + finally: + try: + p.unlink() + except OSError: + pass + return {"ok": True, "message": "Transaction rolled back to snapshot"} + return await self._async(_sync) + + # ── system ──────────────────────────────────────────────────────────────── + + async def system_status(self) -> dict: + has_doc = self._doc is not None + return { + "backend": "ezdxf", + "connected": True, + "has_document": has_doc, + "document_path": self._doc_path, + "unsaved_changes": self._dirty, + "transaction_depth": len(self._undo_stack), + "capabilities": [ + "file_read_write", "dxf_export", "pdf_export", + "entity_creation", "layer_management", "blocks", + "dimensions", "analysis", "screenshot_matplotlib", + ], + } + + async def system_get_variable(self, name) -> Any: + def _sync(): + doc = self._require_doc() + return doc.header.get(f"${name.upper()}", None) + return await self._async(_sync) + + async def system_set_variable(self, name, value) -> dict: + def _sync(): + doc = self._require_doc() + doc.header[f"${name.upper()}"] = value + self._mark_dirty() + return {"ok": True, "variable": name, "value": value} + return await self._async(_sync) + + async def system_run_command(self, command) -> dict: + return { + "ok": False, + "error": "system_run_command not supported in ezdxf backend (no live AutoCAD)", + } + + async def system_run_lisp(self, expression) -> dict: + return { + "ok": False, + "error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)", + } diff --git a/config.py b/config.py new file mode 100644 index 0000000..474df77 --- /dev/null +++ b/config.py @@ -0,0 +1,47 @@ +"""Centralized configuration for AutoCAD MCP Pro.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +class Settings: + """Server configuration loaded from environment variables.""" + + def __init__(self): + self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip() + self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip() + + raw_paths = os.environ.get("ALLOWED_PATHS", "").strip() + self.allowed_paths: list[Path] = [] + if raw_paths: + for p in raw_paths.split(","): + p = p.strip() + if p: + self.allowed_paths.append(Path(p).resolve()) + + self.max_undo_stack: int = int(os.environ.get("MAX_UNDO_STACK", "5")) + self.dangerous_commands_enabled: bool = ( + os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true" + ) + + # Reject opening DXF files larger than this many bytes (default 50 MB). + # Set to 0 to disable the check. + self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(50 * 1024 * 1024))) + + # Hard cap on list/select limit params to keep tool responses bounded. + self.max_list_limit: int = int(os.environ.get("MAX_LIST_LIMIT", "5000")) + + # Per-call COM timeout. AutoCAD hangs (modal dialogs, long Regen) would + # otherwise block the single-thread STA executor forever. + self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60")) + + # HTTP transport: refuse non-loopback bind unless explicitly opted in. + self.allow_remote_http: bool = ( + os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true" + ) + self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip() + + +settings = Settings() diff --git a/fikir.md b/fikir.md new file mode 100644 index 0000000..0cd8624 --- /dev/null +++ b/fikir.md @@ -0,0 +1,442 @@ +# AutoCAD MCP Pro — Geliştirme Fikirleri + +> **Proje:** autocad-mcp-pro v1.0.0 +> **Tarih:** 5 Mart 2026 +> **Durum:** 67 tool, 6 resource, 5 prompt — FastMCP 3.0 + Dual Backend (COM + ezdxf) + +--- + +## Mevcut Durum Özeti + +| Alan | Durum | +|------|-------| +| Tool sayısı | 67 | +| Resource sayısı | 6 | +| Prompt sayısı | 5 | +| Backend | COM (Windows) + ezdxf (cross-platform) | +| Test | 17 test (sadece ezdxf backend) | +| Coverage aracı | Yok | +| CI/CD | Yok | +| README.md | Eksik | +| Auth | Yok | +| Lint/Format | Konfigüre edilmemiş | + +--- + +## 1. GÜVENLİK — Kritik Öncelik + +### 1.1 Path Traversal Koruması + +**Sorun:** `drawing_open`, `drawing_save`, `drawing_save_as`, `drawing_export_pdf` gibi tool'lar doğrudan kullanıcı path'i alıyor. `..` veya mutlak path kontrolü yok. + +**Risk:** `drawing_open("../../../etc/passwd")` veya `C:\Windows\System32\...` gibi saldırılar mümkün. + +**Çözüm:** +- İzin verilen dizinleri tanımlayan `ALLOWED_PATHS` env değişkeni +- `_validate_path()` yardımcı fonksiyonu ile `Path.resolve()` + `is_relative_to()` kontrolü +- Yazma ve okuma işlemleri için ayrı izin seviyeleri + +### 1.2 Komut Enjeksiyonu + +**Sorun:** `system_run_command` ve `system_run_lisp` ham string alıyor. LLM veya kullanıcıdan gelen input ile tehlikeli komutlar çalıştırılabilir. + +**Çözüm:** +- Whitelist tabanlı komut filtresi +- Tehlikeli pattern tespiti (DELETE, ERASE ALL, PURGE vb.) +- Opsiyonel onay mekanizması (user elicitation) + +### 1.3 HTTP Transport Güvenliği + +**Sorun:** `fastmcp run --transport http` modunda kimlik doğrulama yok. + +**Çözüm:** +- FastMCP `AuthMiddleware` ile OAuth 2.1 entegrasyonu +- API key tabanlı basit auth alternatifi +- Rate limiting middleware + +--- + +## 2. KOD KALİTESİ + +### 2.1 Sessiz Hata Yutma + +**Sorun:** Birden fazla yerde `except Exception: pass` kullanımı var. + +| Konum | Sorun | +|-------|-------| +| `ezdxf_backend.py:108-109` | `_entity_info_dxf` içinde hata yutuluyor | +| `ezdxf_backend.py:818-820` | `block_create_from_entities` içinde sessiz except | +| `server.py:60` | `_detect_autocad_running` hata loglamıyor | + +**Çözüm:** Her `except` bloğunda en azından `log.debug()` ile loglama. Kritik yerlerde hata toplama ve raporlama. + +### 2.2 Lint & Format Konfigürasyonu + +**Sorun:** ruff, flake8, mypy, black, isort gibi araçlar tanımlı değil. + +**Çözüm:** +```toml +[tool.ruff] +line-length = 100 +target-version = "py311" +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.11" +strict = true +``` + +### 2.3 Pre-commit Hook + +**Çözüm:** `.pre-commit-config.yaml` ile: +- ruff lint + format +- mypy type check +- pytest --quick + +--- + +## 3. TEST ALTYAPISI + +### 3.1 Mevcut Kapsam (Düşük) + +Sadece ezdxf backend test ediliyor, 17 test var. Test edilmeyen alanlar: + +- **server.py** — MCP sunucusu, middleware'ler, tool kayıtları +- **com_backend.py** — COM backend (mock ile test edilebilir) +- **Drawing işlemleri** — `drawing_new`, `drawing_open`, `drawing_save`, export'lar +- **Entity oluşturma** — circle, arc, polyline, text, mtext, hatch, spline, ellipse, point, block_ref +- **Dimension** — linear, aligned, angular, radius, diameter +- **Entity düzenleme** — copy, offset, array_rectangular, set_properties, delete_many +- **Layer işlemleri** — delete, set_current, modify, freeze, thaw, lock, unlock, hide, show +- **Block** — insert, explode, get/set_attributes +- **Analysis** — stats, entities_in_region, measure_distance, measure_area, bounding_box +- **View / System** — tüm tool'lar + +### 3.2 Coverage Aracı + +**Çözüm:** `pytest-cov` eklenmesi: +```toml +[tool.uv] +dev-dependencies = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", +] +``` + +### 3.3 Test Stratejisi + +| Hedef | Yaklaşım | +|-------|----------| +| ezdxf backend | Birim testleri (mevcut + genişletme) | +| COM backend | Mock tabanlı testler (pywin32 mock) | +| server.py | FastMCP test client ile entegrasyon testleri | +| Middleware | İzole birim testleri | + +### 3.4 Hedef Coverage + +- Kısa vade: %60+ +- Orta vade: %80+ +- Uzun vade: %90+ + +--- + +## 4. CI/CD PİPELINE + +### 4.1 GitHub Actions + +**Çözüm:** `.github/workflows/ci.yml`: +- Python 3.11, 3.12 matrix +- `pip install -e ".[full]"` + dev dependencies +- `ruff check .` + `ruff format --check .` +- `pytest --cov=. --cov-report=xml` +- Coverage badge güncelleme + +### 4.2 Release Otomasyonu + +- Tag tabanlı PyPI publish +- Changelog otomasyonu +- Docker image build (opsiyonel) + +--- + +## 5. PERFORMANS İYİLEŞTİRMELERİ + +### 5.1 Büyük Çizimlerde Entity Listesi + +**Sorun:** `entity_list` tüm modelspace'i iterate ediyor, filtre sonradan uygulanıyor. + +**Çözüm:** +- Backend tarafında filtreleme (COM SelectionSet kullanımı) +- Streaming / generator tabanlı entity erişimi +- Sayfalama (pagination) desteği + +### 5.2 Analysis İşlemleri + +**Sorun:** `analysis_layer_stats` 50.000 limitle tüm entity'leri çekiyor. `analysis_entities_in_region` her entity için bbox hesaplıyor. + +**Çözüm:** +- Batch işleme ve streaming +- Spatial index (R-tree) kullanımı +- Sonuç cache'leme (değişiklik yoksa) + +### 5.3 Undo Stack Bellek Kullanımı + +**Sorun:** ezdxf backend'de tam DXF snapshot'ları `_undo_stack`'te saklanıyor. + +**Çözüm:** +- Delta tabanlı undo (sadece değişiklikleri kaydetme) +- Maksimum undo sayısı sınırı +- Snapshot sıkıştırma + +### 5.4 Tool Pagination + +**Sorun:** 67 araç tek listede sunuluyor. + +**Çözüm:** FastMCP `list_page_size` parametresi ile pagination: +```python +mcp = FastMCP("AutoCAD MCP Pro", list_page_size=20) +``` + +--- + +## 6. YENİ ÖZELLİK FİKİRLERİ + +### 6.1 DWG Doğrudan Desteği + +**Mevcut:** ezdxf yalnızca DXF okuyabiliyor. + +**Fikir:** ODA File Converter veya LibreDWG ile DWG → DXF otomatik dönüşüm pipeline'ı. + +### 6.2 Batch İşlem Desteği + +**Fikir:** Tek bir çağrıda birden fazla entity oluşturma/düzenleme: +```python +@mcp.tool() +async def entity_batch_create(entities: list[dict], ctx: Context) -> list[EntityInfo]: + """Tek çağrıda birden fazla entity oluştur""" +``` + +### 6.3 Template Sistemi + +**Fikir:** Hazır çizim şablonları: +- Başlık bloğu (title block) +- Ölçek barı +- Kuzey oku +- Standart katman setleri (mimari, mekanik, elektrik) + +### 6.4 Stil Yönetimi + +**Fikir:** Text style, dimension style, linetype yönetimi tool'ları: +``` +style_text_create, style_text_list +style_dimension_create, style_dimension_list +linetype_load, linetype_list +``` + +### 6.5 Koordinat Sistemi Desteği + +**Fikir:** UCS (User Coordinate System) yönetimi: +``` +ucs_set, ucs_get, ucs_reset, ucs_rotate +``` + +### 6.6 Annotation Araçları + +**Fikir:** Leader, multileader, revision cloud, wipeout: +``` +entity_create_leader, entity_create_multileader +entity_create_revision_cloud, entity_create_wipeout +``` + +### 6.7 Xref Yönetimi + +**Fikir:** Harici referans dosyaları: +``` +xref_attach, xref_detach, xref_reload, xref_list, xref_bind +``` + +### 6.8 Layout & Viewport + +**Fikir:** Paper space ve viewport yönetimi: +``` +layout_create, layout_list, layout_delete +viewport_create, viewport_set_scale, viewport_freeze_layer +``` + +### 6.9 Table & Schedule + +**Fikir:** Tablo oluşturma ve veri çıkarma: +``` +table_create, table_set_data, table_extract_data +schedule_create_from_blocks +``` + +### 6.10 Export Genişletme + +**Fikir:** Ek export formatları: +- SVG export (web görüntüleme) +- PNG/JPEG export (ezdxf matplotlib backend) +- STEP/IGES export (3D modeller için) +- JSON export (veri analizi) + +--- + +## 7. KULLANICI DENEYİMİ + +### 7.1 Progress Bildirimi + +**Fikir:** Uzun süren işlemlerde `ctx.report_progress()` kullanımı: +```python +for i, entity in enumerate(entities): + await ctx.report_progress(i, total) +``` + +### 7.2 Daha İyi Hata Mesajları + +**Fikir:** Hata mesajlarında çözüm önerisi: +``` +"Layer 'WALLS' bulunamadı. Mevcut katmanlar: 0, DOORS, WINDOWS. + layer_create('WALLS') ile oluşturabilirsiniz." +``` + +### 7.3 Akıllı Varsayılanlar + +**Fikir:** Entity oluştururken akıllı varsayılanlar: +- Mevcut katmanı otomatik kullan +- Son kullanılan renk/linetype'ı hatırla +- Standart ölçü stilleri otomatik yükle + +### 7.4 Çizim Doğrulama + +**Fikir:** Çizim kalite kontrol tool'u: +``` +validation_check_overlaps — Üst üste binen entity'ler +validation_check_gaps — Bağlanmamış çizgiler +validation_check_standards — Katman/stil standartlarına uyum +validation_check_scale — Ölçek tutarlılığı +``` + +--- + +## 8. DÖKÜMANTASYON + +### 8.1 README.md + +**Durum:** Eksik (pyproject.toml referans veriyor ama dosya yok). + +**İçerik:** +- Proje açıklaması ve özellikler +- Kurulum adımları +- Kullanım örnekleri +- Backend seçimi rehberi +- Tool listesi ve açıklamaları +- Katkıda bulunma rehberi + +### 8.2 API Dokümantasyonu + +**Fikir:** Her tool grubu için detaylı dokümantasyon: +- Parametre açıklamaları +- Örnek kullanımlar +- Hata senaryoları +- İlişkili tool'lar + +### 8.3 Örnek Projeler + +**Fikir:** `examples/` dizini altında: +- `01_basic_drawing.py` — Temel çizim oluşturma +- `02_floor_plan.py` — Kat planı oluşturma +- `03_batch_operations.py` — Toplu işlemler +- `04_analysis.py` — Çizim analizi +- `05_export.py` — Farklı formatlara export + +### 8.4 .env.example + +```env +AUTOCAD_MCP_BACKEND=auto +# ALLOWED_PATHS=/path/to/drawings +# LOG_LEVEL=INFO +``` + +--- + +## 9. ALTYAPI + +### 9.1 Docker Desteği + +**Fikir:** ezdxf backend için Dockerfile: +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY . . +RUN pip install -e . +CMD ["python", "server.py"] +``` + +### 9.2 OpenTelemetry + +**Fikir:** Tracing ve metrics: +- Tool çağrı süreleri +- Hata oranları +- Backend durumu +- Entity sayısı metrikleri + +### 9.3 Logging İyileştirme + +**Fikir:** Structured logging (JSON format): +```python +import structlog +log = structlog.get_logger() +``` + +### 9.4 Konfigürasyon Yönetimi + +**Fikir:** pydantic-settings ile merkezi konfigürasyon: +```python +class Settings(BaseSettings): + backend: str = "auto" + allowed_paths: list[str] = [] + max_undo_stack: int = 50 + log_level: str = "INFO" +``` + +--- + +## 10. ÖNCELİK MATRİSİ + +| # | Fikir | Öncelik | Etki | Zorluk | +|---|-------|---------|------|--------| +| 1 | Path traversal koruması | 🔴 Kritik | Yüksek | Düşük | +| 2 | Komut enjeksiyonu koruması | 🔴 Kritik | Yüksek | Orta | +| 3 | README.md oluşturma | 🟠 Yüksek | Orta | Düşük | +| 4 | Sessiz except düzeltme | 🟠 Yüksek | Orta | Düşük | +| 5 | Ruff lint konfigürasyonu | 🟠 Yüksek | Orta | Düşük | +| 6 | Test kapsamı genişletme | 🟠 Yüksek | Yüksek | Orta | +| 7 | CI/CD pipeline | 🟠 Yüksek | Yüksek | Orta | +| 8 | Batch işlem desteği | 🟡 Orta | Yüksek | Orta | +| 9 | Template sistemi | 🟡 Orta | Yüksek | Orta | +| 10 | Stil yönetimi tool'ları | 🟡 Orta | Orta | Düşük | +| 11 | Annotation araçları | 🟡 Orta | Orta | Orta | +| 12 | Layout & viewport | 🟡 Orta | Yüksek | Yüksek | +| 13 | Performans iyileştirmeleri | 🟡 Orta | Orta | Orta | +| 14 | Progress bildirimi | 🟡 Orta | Orta | Düşük | +| 15 | Çizim doğrulama | 🟢 Düşük | Orta | Orta | +| 16 | Docker desteği | 🟢 Düşük | Düşük | Düşük | +| 17 | OpenTelemetry | 🟢 Düşük | Düşük | Orta | +| 18 | DWG doğrudan desteği | 🟢 Düşük | Yüksek | Yüksek | +| 19 | Xref yönetimi | 🟢 Düşük | Orta | Yüksek | +| 20 | SVG/JSON export | 🟢 Düşük | Orta | Orta | + +--- + +## Sonuç + +AutoCAD MCP Pro, güçlü bir temele sahip. 67 tool ile zengin işlevsellik sunuyor ve dual-backend mimarisi esneklik sağlıyor. Öncelikli iyileştirme alanları: + +1. **Güvenlik** — Path ve komut koruması production için zorunlu +2. **Kalite** — Lint, test, CI/CD ile sürdürülebilirlik +3. **Yeni özellikler** — Batch, template, stil, annotation ile profesyonel CAD iş akışları +4. **Altyapı** — Docker, telemetry, structured logging ile operasyonel olgunluk diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5120e92 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "autocad-mcp-pro" +version = "1.0.0" +description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastmcp>=3.0", + "ezdxf>=1.3", + "pydantic>=2.0", +] + +[project.optional-dependencies] +com = [ + "pywin32>=306", + "Pillow>=10.0", +] +pdf = [ + "matplotlib>=3.8", +] +full = [ + "pywin32>=306", + "Pillow>=10.0", + "matplotlib>=3.8", +] + +[project.scripts] +autocad-mcp = "server:mcp" + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", + "ruff>=0.8", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +asyncio_mode = "strict" diff --git a/security.py b/security.py new file mode 100644 index 0000000..39d2dff --- /dev/null +++ b/security.py @@ -0,0 +1,227 @@ +"""Security utilities for AutoCAD MCP Pro. + +Path traversal protection and command injection prevention. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path + +from fastmcp.exceptions import ToolError + +import config + +log = logging.getLogger("autocad_mcp.security") + +# --------------------------------------------------------------------------- +# Path validation +# --------------------------------------------------------------------------- + +_BLOCKED_PATH_PATTERNS = re.compile( + r"(\.\.[\\/])" + r"|(%2e%2e)" + r"|(\\\\[?.]\\)" + r"|(^\\\\)" + r"|(\x00)", + re.IGNORECASE, +) + + +def validate_path(path: str, *, allow_write: bool = False) -> Path: + """Resolve and validate a file path against security rules. + + Raises ToolError if: + - Path contains traversal sequences (..) + - Path is outside ALLOWED_PATHS (when configured) + - Path targets a system directory + """ + if not path or not path.strip(): + raise ToolError("File path cannot be empty.") + + if _BLOCKED_PATH_PATTERNS.search(path): + log.warning("Blocked path traversal attempt: %s", path) + raise ToolError( + f"Path rejected: suspicious traversal pattern detected in '{path}'. " + "Use absolute paths without '..' components." + ) + + resolved = Path(path).resolve() + + _SYSTEM_DIRS = [ + Path("C:/Windows").resolve(), + Path("C:/Program Files").resolve(), + Path("C:/Program Files (x86)").resolve(), + Path("/etc").resolve(), + Path("/usr").resolve(), + Path("/bin").resolve(), + Path("/sbin").resolve(), + Path("/boot").resolve(), + Path("/sys").resolve(), + Path("/proc").resolve(), + ] + for sys_dir in _SYSTEM_DIRS: + try: + if resolved.is_relative_to(sys_dir): + log.warning("Blocked system directory access: %s", resolved) + raise ToolError( + f"Path rejected: '{resolved}' is inside a system directory. " + "File operations in system directories are not allowed." + ) + except (ValueError, TypeError): + pass + + if config.settings.allowed_paths: + allowed = False + for allowed_dir in config.settings.allowed_paths: + try: + if resolved.is_relative_to(allowed_dir): + allowed = True + break + except (ValueError, TypeError): + pass + if not allowed: + allowed_str = ", ".join(str(p) for p in config.settings.allowed_paths) + log.warning("Path outside allowed directories: %s", resolved) + raise ToolError( + f"Path rejected: '{resolved}' is not inside any allowed directory. " + f"Allowed directories: {allowed_str}. " + "Set ALLOWED_PATHS environment variable to configure." + ) + + if allow_write: + parent = resolved.parent + if not parent.exists(): + raise ToolError( + f"Parent directory does not exist: '{parent}'. " + "Create the directory first or use a valid path." + ) + + return resolved + + +# --------------------------------------------------------------------------- +# Command / LISP sanitization +# --------------------------------------------------------------------------- + +_DANGEROUS_COMMANDS = [ + "ERASE", "_ERASE", + "DELETE", "_DELETE", + "PURGE", "_PURGE", + "QUIT", "_QUIT", + "CLOSE", "_CLOSE", + "QSAVE", "_QSAVE", + "SAVEAS", "_SAVEAS", + "NEW", "_NEW", + "OPEN", "_OPEN", + "RECOVER", "_RECOVER", + "WBLOCK", "_WBLOCK", + "INSERT", "_INSERT", + "XREF", "_XREF", + "SHELL", "_SHELL", + "SCRIPT", "_SCRIPT", + "APPLOAD", "_APPLOAD", + "NETLOAD", "_NETLOAD", + "VBARUN", "_VBARUN", + "VBALOAD", "_VBALOAD", + "SECURITYOPTIONS", "_SECURITYOPTIONS", +] + +_DANGEROUS_COMMAND_PATTERNS = re.compile( + r"\b(" + + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + + r")\b", + re.IGNORECASE, +) + +# Dangerous LISP symbols. We match these as **bare tokens anywhere** in the +# expression — not only when they appear right after `(` — because aliasing +# tricks like `(setq f command)(f "ERASE")` reference the symbol as a value. +_DANGEROUS_LISP_PATTERNS = [ + # File and shell I/O + r"startapp", + r"dos_[\w-]*", + r"vl-file-(?:delete|rename|copy|directory-p)", + r"vl-mkdir", + r"vl-directory-files", + r"read-line", + r"write-line", + r"findfile", + r"getfiled", + # Command execution (every known channel) + r"command(?:-s)?", + r"vl-cmdf", + # ActiveX/COM family — vla-*, vlax-*, vlr-*; covers vla-SendCommand etc. + r"vla[xr]?-[\w-]+", + # AutoCAD Express Tools — acet-sys-shell and friends + r"acet-[\w-]+", + # Code loading + r"vl-acad-(?:de|un)fun", + r"(?:auto)?load", + r"arxload", + # Indirection vectors that bypass head-only matching + r"eval", + r"apply", + r"funcall", + r"function", + r"\(\s*quote\s+command\b", + # Custom command invocation prefix `(c:my-cmd)` + r"c:[\w-]+", + # Error handler hijack + r"\*push-error-using-command\*", + r"\*push-error-using-stack\*", +] + +_DANGEROUS_LISP_PATTERN = re.compile( + r"(?:(? str: + """Validate an AutoCAD command string for safety. + + Raises ToolError if a dangerous command pattern is detected + (unless DANGEROUS_COMMANDS_ENABLED is true). + """ + if not command or not command.strip(): + raise ToolError("Command string cannot be empty.") + + if config.settings.dangerous_commands_enabled: + log.info("Dangerous commands enabled — bypassing sanitization for: %s", command[:80]) + return command + + if _DANGEROUS_COMMAND_PATTERNS.search(command): + log.warning("Blocked dangerous command: %s", command[:120]) + raise ToolError( + f"Command rejected: '{command[:80]}' contains a restricted command. " + "Restricted commands include ERASE, DELETE, PURGE, QUIT, SHELL, SCRIPT, etc. " + "Set DANGEROUS_COMMANDS_ENABLED=true to allow all commands." + ) + + return command + + +def sanitize_lisp(expression: str) -> str: + """Validate an AutoLISP expression for safety. + + Raises ToolError if a dangerous LISP function is detected + (unless DANGEROUS_COMMANDS_ENABLED is true). + """ + if not expression or not expression.strip(): + raise ToolError("LISP expression cannot be empty.") + + if config.settings.dangerous_commands_enabled: + log.info("Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]) + return expression + + if _DANGEROUS_LISP_PATTERN.search(expression): + log.warning("Blocked dangerous LISP expression: %s", expression[:120]) + raise ToolError( + "LISP expression rejected: contains a restricted function. " + "Restricted functions include startapp, command, load, eval, file I/O, etc. " + "Set DANGEROUS_COMMANDS_ENABLED=true to allow all LISP expressions." + ) + + return expression diff --git a/server.py b/server.py new file mode 100644 index 0000000..5bf24e4 --- /dev/null +++ b/server.py @@ -0,0 +1,2350 @@ +"""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 + +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, + ctx: Context = None, +) -> dict: + """Create a new empty drawing, optionally from a template (.dwt).""" + 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})") + return await _backend(ctx).drawing_new(template) + + +@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) + + +@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} + + +# --------------------------------------------------------------------------- +# ── 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' + """ + 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"], + "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, + } + + +# --------------------------------------------------------------------------- +# ── 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= 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= 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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e0e5612 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared fixtures for AutoCAD MCP tests.""" + +import pytest_asyncio + +from backends.ezdxf_backend import EzdxfBackend + + +@pytest_asyncio.fixture +async def backend(): + """Create an ezdxf backend with a fresh empty document.""" + b = EzdxfBackend() + await b.connect() + await b.drawing_new() + yield b + await b.disconnect() diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..d591f89 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,114 @@ +"""Tests for analysis tools and entity modification via ezdxf backend.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Entity modification tests +# --------------------------------------------------------------------------- + + +async def test_entity_copy(backend): + line = await backend.entity_create_line(0, 0, 100, 0) + copy = await backend.entity_copy(line.handle, 0, 50) + assert copy.handle + assert copy.handle != line.handle + entities = await backend.entity_list() + assert len(entities) == 2 + + +async def test_entity_offset(backend): + line = await backend.entity_create_line(0, 0, 100, 0) + result = await backend.entity_offset(line.handle, 10) + assert result.handle + + +async def test_entity_array_rectangular(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + copies = await backend.entity_array_rectangular(line.handle, 3, 4, 20, 30) + assert len(copies) == 11 + + +async def test_entity_set_properties(backend): + await backend.layer_create("PROP_LAYER", color=5) + line = await backend.entity_create_line(0, 0, 100, 0) + result = await backend.entity_set_properties(line.handle, layer="PROP_LAYER", color=3) + assert result.get("ok") is True + got = await backend.entity_get(line.handle) + assert got.layer == "PROP_LAYER" + assert got.color == 3 + + +async def test_entity_delete_many(backend): + h1 = (await backend.entity_create_line(0, 0, 10, 0)).handle + h2 = (await backend.entity_create_line(10, 0, 20, 0)).handle + h3 = (await backend.entity_create_line(20, 0, 30, 0)).handle + entities_before = await backend.entity_list() + assert len(entities_before) == 3 + await backend.entity_delete(h1) + await backend.entity_delete(h2) + await backend.entity_delete(h3) + entities_after = await backend.entity_list() + assert len(entities_after) == 0 + + +# --------------------------------------------------------------------------- +# Analysis tests +# --------------------------------------------------------------------------- + + +async def test_analysis_entity_stats(backend): + await backend.entity_create_line(0, 0, 100, 0) + await backend.entity_create_circle(50, 50, 25) + await backend.entity_create_line(0, 100, 100, 100) + stats = await backend.analysis_stats() + assert isinstance(stats, dict) + by_type = stats.get("by_type", stats) + total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0)) + assert total >= 3 + + +async def test_analysis_find_in_region(backend): + await backend.entity_create_line(10, 10, 20, 20) + await backend.entity_create_line(500, 500, 600, 600) + result = await backend.analysis_entities_in_region(0, 0, 50, 50) + assert len(result) >= 1 + + +async def test_analysis_measure_distance(backend): + dist = await backend.analysis_measure_distance(0, 0, 3, 4) + assert dist == pytest.approx(5.0, abs=0.001) + + +async def test_analysis_measure_area(backend): + area = await backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]]) + assert area == pytest.approx(100.0, abs=0.01) + + +async def test_analysis_bounding_box(backend): + await backend.entity_create_line(0, 0, 100, 0) + await backend.entity_create_line(0, 0, 0, 50) + bbox = await backend.analysis_bounding_box() + assert isinstance(bbox, dict) + + +async def test_analysis_select_by_layer(backend): + await backend.layer_create("SEL_LAYER") + await backend.entity_create_line(0, 0, 10, 0, layer="SEL_LAYER") + await backend.entity_create_circle(50, 50, 5) + result = await backend.analysis_select_by_layer("SEL_LAYER") + assert len(result) == 1 + assert result[0].layer == "SEL_LAYER" + + +async def test_analysis_select_by_type(backend): + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_circle(50, 50, 5) + await backend.entity_create_line(0, 100, 10, 100) + result = await backend.analysis_select_by_type("CIRCLE") + assert len(result) == 1 + assert result[0].type == "CIRCLE" diff --git a/tests/test_batch_template.py b/tests/test_batch_template.py new file mode 100644 index 0000000..d693bab --- /dev/null +++ b/tests/test_batch_template.py @@ -0,0 +1,128 @@ +"""Tests for batch operations, templates, and validation.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Batch create tests +# --------------------------------------------------------------------------- + + +async def test_batch_create_lines(backend): + """Batch create should work via individual backend calls.""" + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_line(10, 0, 20, 0) + await backend.entity_create_circle(5, 5, 3) + entities = await backend.entity_list() + assert len(entities) == 3 + + +async def test_batch_modify_move(backend): + """Batch move multiple entities.""" + line = await backend.entity_create_line(0, 0, 100, 0) + await backend.entity_move(line.handle, 10, 20) + got = await backend.entity_get(line.handle) + props = got.properties or {} + start = props.get("start", []) + if start: + assert abs(start[0] - 10) < 0.01 + assert abs(start[1] - 20) < 0.01 + + +async def test_batch_modify_delete(backend): + """Batch delete multiple entities.""" + h1 = (await backend.entity_create_line(0, 0, 10, 0)).handle + h2 = (await backend.entity_create_line(10, 0, 20, 0)).handle + await backend.entity_delete(h1) + await backend.entity_delete(h2) + entities = await backend.entity_list() + assert len(entities) == 0 + + +# --------------------------------------------------------------------------- +# Template tests +# --------------------------------------------------------------------------- + + +async def test_template_architectural_layers(backend): + """Architectural template should create standard layers.""" + layer_defs = [ + ("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4), + ("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7), + ] + for name, color in layer_defs: + await backend.layer_create(name, color=color) + + layers = await backend.layer_list() + names = {lyr.name for lyr in layers} + assert "WALLS" in names + assert "DOORS" in names + assert "WINDOWS" in names + + +async def test_template_mechanical_layers(backend): + """Mechanical template layers.""" + layer_defs = [ + ("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3), + ("DIMENSIONS", 2), ("SECTION", 5), + ] + for name, color in layer_defs: + await backend.layer_create(name, color=color) + + layers = await backend.layer_list() + names = {lyr.name for lyr in layers} + assert "VISIBLE" in names + assert "HIDDEN" in names + assert "CENTER" in names + + +async def test_template_electrical_layers(backend): + """Electrical template layers.""" + await backend.layer_create("POWER_LINES", color=7) + await backend.layer_create("CONTROL_LINES", color=3) + layers = await backend.layer_list() + names = {lyr.name for lyr in layers} + assert "POWER_LINES" in names + assert "CONTROL_LINES" in names + + +# --------------------------------------------------------------------------- +# Validation tests +# --------------------------------------------------------------------------- + + +async def test_validation_empty_layers(backend): + """Empty layers should be detected.""" + await backend.layer_create("EMPTY_LAYER") + layers = await backend.layer_list() + entities = await backend.entity_list(limit=50000) + used = {e.layer for e in entities} + empty = [lyr for lyr in layers if lyr.name != "0" and lyr.name not in used] + assert len(empty) >= 1 + assert any(lyr.name == "EMPTY_LAYER" for lyr in empty) + + +async def test_validation_no_issues_clean_drawing(backend): + """A clean drawing with no empty layers should have no issues.""" + await backend.entity_create_line(0, 0, 100, 0) + entities = await backend.entity_list() + assert len(entities) >= 1 + + +async def test_validation_zero_length_detection(backend): + """Zero-length lines should be detectable.""" + await backend.entity_create_line(50, 50, 50, 50) + lines = await backend.entity_list(type_filter="LINE") + assert len(lines) == 1 + props = lines[0].properties or {} + start = props.get("start", []) + end = props.get("end", []) + if start and end: + dx = end[0] - start[0] + dy = end[1] - start[1] + length = (dx * dx + dy * dy) ** 0.5 + assert length < 0.01 diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py new file mode 100644 index 0000000..e3430e3 --- /dev/null +++ b/tests/test_dimensions.py @@ -0,0 +1,40 @@ +"""Tests for dimension creation via ezdxf backend.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_dimension_linear(backend): + info = await backend.dimension_linear(0, 0, 100, 0, 50, 20, rotation=0) + assert info.handle + assert info.type == "DIMENSION" + + +@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_aligned_dim", strict=False) +async def test_dimension_aligned(backend): + info = await backend.dimension_aligned(0, 0, 100, 50, 50, 60) + assert info.handle + assert info.type == "DIMENSION" + + +@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_angular_dim_2l", strict=False) +async def test_dimension_angular(backend): + info = await backend.dimension_angular(0, 0, 100, 0, 0, 100, 50, 50) + assert info.handle + assert info.type == "DIMENSION" + + +async def test_dimension_radius(backend): + await backend.entity_create_circle(50, 50, 30) + info = await backend.dimension_radius(50, 50, 80, 50, leader_length=15) + assert info.handle + assert info.type == "DIMENSION" + + +async def test_dimension_diameter(backend): + info = await backend.dimension_diameter(20, 50, 80, 50, leader_length=10) + assert info.handle + assert info.type == "DIMENSION" diff --git a/tests/test_drawing.py b/tests/test_drawing.py new file mode 100644 index 0000000..5c4cd69 --- /dev/null +++ b/tests/test_drawing.py @@ -0,0 +1,58 @@ +"""Tests for drawing operations via ezdxf backend.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_drawing_info(backend): + info = await backend.drawing_info() + assert info.backend == "ezdxf" + assert info.entity_count >= 0 + + +async def test_drawing_new(backend): + result = await backend.drawing_new() + assert result.get("ok") is True + + +async def test_drawing_save_and_open(backend, tmp_path): + await backend.entity_create_line(0, 0, 100, 0) + save_path = str(tmp_path / "test_save.dxf") + await backend.drawing_save(save_path) + await backend.drawing_open(save_path) + info = await backend.drawing_info() + assert info.entity_count >= 1 + + +async def test_drawing_export_dxf(backend, tmp_path): + await backend.entity_create_circle(0, 0, 50) + export_path = str(tmp_path / "export.dxf") + result = await backend.drawing_export_dxf(export_path) + assert result.get("ok") is True + import os + assert os.path.exists(export_path) + + +async def test_drawing_purge(backend): + await backend.layer_create("TEMP_LAYER") + result = await backend.drawing_purge() + assert result.get("ok") is True + + +async def test_drawing_audit(backend): + result = await backend.drawing_audit() + assert result.get("ok") is True + + +async def test_drawing_undo(backend): + await backend.entity_create_line(0, 0, 100, 0) + result = await backend.drawing_undo() + assert isinstance(result, dict) + + +async def test_drawing_redo(backend): + result = await backend.drawing_redo() + assert isinstance(result, dict) diff --git a/tests/test_entity_creation.py b/tests/test_entity_creation.py new file mode 100644 index 0000000..6a8236c --- /dev/null +++ b/tests/test_entity_creation.py @@ -0,0 +1,100 @@ +"""Tests for entity creation — all entity types via ezdxf backend.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_create_circle(backend): + info = await backend.entity_create_circle(50, 50, 25) + assert info.handle + assert info.type == "CIRCLE" + got = await backend.entity_get(info.handle) + assert got.type == "CIRCLE" + assert got.properties.get("radius") == pytest.approx(25, abs=0.01) + + +async def test_create_arc(backend): + info = await backend.entity_create_arc(0, 0, 50, 0, 90) + assert info.handle + assert info.type == "ARC" + + +async def test_create_polyline(backend): + pts = [[0, 0], [100, 0], [100, 100], [0, 100]] + info = await backend.entity_create_polyline(pts, closed=True) + assert info.handle + assert info.type == "LWPOLYLINE" + + +async def test_create_polyline_open(backend): + pts = [[0, 0], [50, 50], [100, 0]] + info = await backend.entity_create_polyline(pts, closed=False) + assert info.handle + assert info.type == "LWPOLYLINE" + + +async def test_create_text(backend): + info = await backend.entity_create_text("Hello", 10, 20, height=5.0) + assert info.handle + assert info.type == "TEXT" + + +async def test_create_mtext(backend): + info = await backend.entity_create_mtext("Multi\\Pline Text", 0, 0, width=200) + assert info.handle + assert info.type == "MTEXT" + + +async def test_create_hatch(backend): + boundary = [[0, 0], [100, 0], [100, 100], [0, 100]] + info = await backend.entity_create_hatch("SOLID", boundary, scale=1.0, angle=0) + assert info.handle + assert info.type == "HATCH" + + +async def test_create_spline(backend): + pts = [[0, 0], [25, 50], [50, 25], [75, 75], [100, 0]] + info = await backend.entity_create_spline(pts) + assert info.handle + assert info.type == "SPLINE" + + +async def test_create_ellipse(backend): + info = await backend.entity_create_ellipse(50, 50, 40, 0, ratio=0.5) + assert info.handle + assert info.type == "ELLIPSE" + + +async def test_create_point(backend): + info = await backend.entity_create_point(10, 20) + assert info.handle + assert info.type == "POINT" + + +async def test_create_line_with_layer(backend): + await backend.layer_create("TEST_LAYER", color=1) + info = await backend.entity_create_line(0, 0, 100, 0, layer="TEST_LAYER") + assert info.handle + assert info.layer == "TEST_LAYER" + + +async def test_create_circle_with_color(backend): + info = await backend.entity_create_circle(0, 0, 10, color=3) + assert info.handle + got = await backend.entity_get(info.handle) + assert got.color == 3 + + +async def test_create_entity_appears_in_list(backend): + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_circle(50, 50, 5) + await backend.entity_create_point(0, 0) + entities = await backend.entity_list() + assert len(entities) == 3 + types = {e.type for e in entities} + assert "LINE" in types + assert "CIRCLE" in types + assert "POINT" in types diff --git a/tests/test_ezdxf_backend.py b/tests/test_ezdxf_backend.py new file mode 100644 index 0000000..531c915 --- /dev/null +++ b/tests/test_ezdxf_backend.py @@ -0,0 +1,203 @@ +"""Tests for ezdxf backend — no AutoCAD required.""" + + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Basic CRUD +# --------------------------------------------------------------------------- + + +async def test_create_line(backend): + info = await backend.entity_create_line(0, 0, 100, 0) + assert info.handle + assert info.type == "LINE" + + +async def test_get_entity(backend): + info = await backend.entity_create_line(0, 0, 50, 50) + fetched = await backend.entity_get(info.handle) + assert fetched.handle == info.handle + assert fetched.type == "LINE" + + +async def test_move_entity(backend): + info = await backend.entity_create_line(0, 0, 10, 0) + result = await backend.entity_move(info.handle, 5, 5) + assert result["ok"] + moved = await backend.entity_get(info.handle) + start = moved.properties["start"] + assert abs(start[0] - 5) < 1e-6 + assert abs(start[1] - 5) < 1e-6 + + +async def test_delete_entity(backend): + info = await backend.entity_create_line(0, 0, 10, 0) + result = await backend.entity_delete(info.handle) + assert result["ok"] + entities = await backend.entity_list() + assert len(entities) == 0 + + +# --------------------------------------------------------------------------- +# Entity filtering — LINE vs LWPOLYLINE +# --------------------------------------------------------------------------- + + +async def test_entity_list_type_filter_exact_match(backend): + """LINE filter must NOT match LWPOLYLINE.""" + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=False) + + lines = await backend.entity_list(type_filter="LINE") + polylines = await backend.entity_list(type_filter="LWPOLYLINE") + + assert len(lines) == 1 + assert lines[0].type == "LINE" + assert len(polylines) == 1 + assert polylines[0].type == "LWPOLYLINE" + + +async def test_analysis_select_by_type_exact_match(backend): + """analysis_select_by_type must use exact match, not substring.""" + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=False) + + lines = await backend.analysis_select_by_type("LINE") + assert len(lines) == 1 + assert lines[0].type == "LINE" + + +# --------------------------------------------------------------------------- +# Matrix transforms +# --------------------------------------------------------------------------- + + +async def test_rotate_around_origin(backend): + """Rotate a line (10,0)-(20,0) by 90° around origin → (0,10)-(0,20).""" + info = await backend.entity_create_line(10, 0, 20, 0) + await backend.entity_rotate(info.handle, 0, 0, 90) + rotated = await backend.entity_get(info.handle) + start = rotated.properties["start"] + end = rotated.properties["end"] + assert abs(start[0] - 0) < 1e-4 + assert abs(start[1] - 10) < 1e-4 + assert abs(end[0] - 0) < 1e-4 + assert abs(end[1] - 20) < 1e-4 + + +async def test_rotate_around_point(backend): + """Rotate (10,0)-(20,0) by 90° around (10,0) → (10,0)-(10,10).""" + info = await backend.entity_create_line(10, 0, 20, 0) + await backend.entity_rotate(info.handle, 10, 0, 90) + rotated = await backend.entity_get(info.handle) + start = rotated.properties["start"] + end = rotated.properties["end"] + assert abs(start[0] - 10) < 1e-4 + assert abs(start[1] - 0) < 1e-4 + assert abs(end[0] - 10) < 1e-4 + assert abs(end[1] - 10) < 1e-4 + + +async def test_scale_from_origin(backend): + """Scale line (10,0)-(20,0) by 2x from origin → (20,0)-(40,0).""" + info = await backend.entity_create_line(10, 0, 20, 0) + await backend.entity_scale(info.handle, 0, 0, 2) + scaled = await backend.entity_get(info.handle) + start = scaled.properties["start"] + end = scaled.properties["end"] + assert abs(start[0] - 20) < 1e-4 + assert abs(end[0] - 40) < 1e-4 + + +async def test_scale_from_point(backend): + """Scale line (10,0)-(20,0) by 2x from (10,0) → (10,0)-(30,0).""" + info = await backend.entity_create_line(10, 0, 20, 0) + await backend.entity_scale(info.handle, 10, 0, 2) + scaled = await backend.entity_get(info.handle) + start = scaled.properties["start"] + end = scaled.properties["end"] + assert abs(start[0] - 10) < 1e-4 + assert abs(end[0] - 30) < 1e-4 + + +async def test_mirror_across_y_axis(backend): + """Mirror line (10,0)-(20,0) across Y axis → (-10,0)-(-20,0).""" + info = await backend.entity_create_line(10, 0, 20, 0) + mirrored = await backend.entity_mirror(info.handle, 0, 0, 0, 10) + start = mirrored.properties["start"] + end = mirrored.properties["end"] + assert abs(start[0] - (-10)) < 1e-4 + assert abs(end[0] - (-20)) < 1e-4 + + +async def test_array_polar(backend): + """Polar array of 4 items at 360° around center should create 3 copies.""" + info = await backend.entity_create_line(10, 0, 20, 0) + copies = await backend.entity_array_polar(info.handle, 4, 360, 0, 0) + assert len(copies) == 3 # 4 total - 1 original + + +# --------------------------------------------------------------------------- +# Transactions +# --------------------------------------------------------------------------- + + +async def test_transaction_rollback(backend): + """After rollback, entity created inside transaction should be gone.""" + await backend.transaction_begin() + await backend.entity_create_line(0, 0, 100, 0) + entities_before = await backend.entity_list() + assert len(entities_before) == 1 + + await backend.transaction_rollback() + entities_after = await backend.entity_list() + assert len(entities_after) == 0 + + +async def test_transaction_commit(backend): + """After commit, entity should still exist.""" + await backend.transaction_begin() + await backend.entity_create_line(0, 0, 100, 0) + await backend.transaction_commit() + + entities = await backend.entity_list() + assert len(entities) == 1 + + +# --------------------------------------------------------------------------- +# block_create_from_entities +# --------------------------------------------------------------------------- + + +async def test_block_create_from_entities(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + circle = await backend.entity_create_circle(5, 5, 3) + + result = await backend.block_create_from_entities( + "TestBlock", [line.handle, circle.handle], 0, 0 + ) + assert result["ok"] + assert result["entity_count"] == 2 + + blocks = await backend.block_list() + names = [b.name for b in blocks] + assert "TestBlock" in names + + +# --------------------------------------------------------------------------- +# Layer operations +# --------------------------------------------------------------------------- + + +async def test_layer_create_and_list(backend): + layer = await backend.layer_create("TestLayer", color=1) + assert layer.name == "TestLayer" + assert layer.color == 1 + + layers = await backend.layer_list() + names = [lyr.name for lyr in layers] + assert "TestLayer" in names diff --git a/tests/test_layer_block.py b/tests/test_layer_block.py new file mode 100644 index 0000000..7a6fa52 --- /dev/null +++ b/tests/test_layer_block.py @@ -0,0 +1,113 @@ +"""Tests for layer and block operations via ezdxf backend.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Layer tests +# --------------------------------------------------------------------------- + + +async def test_layer_create(backend): + result = await backend.layer_create("WALLS", color=1) + assert result.name == "WALLS" + assert result.color == 1 + + +async def test_layer_list(backend): + await backend.layer_create("A") + await backend.layer_create("B") + layers = await backend.layer_list() + names = [lyr.name for lyr in layers] + assert "A" in names + assert "B" in names + + +async def test_layer_delete(backend): + await backend.layer_create("TEMP") + result = await backend.layer_delete("TEMP") + assert result.get("ok") is True + layers = await backend.layer_list() + names = [lyr.name for lyr in layers] + assert "TEMP" not in names + + +async def test_layer_set_current(backend): + await backend.layer_create("CURRENT_TEST") + result = await backend.layer_set_current("CURRENT_TEST") + assert result.get("ok") is True + + +async def test_layer_modify(backend): + await backend.layer_create("MOD_LAYER", color=1) + result = await backend.layer_modify("MOD_LAYER", color=3) + assert result.color == 3 + + +async def test_layer_freeze_thaw(backend): + await backend.layer_create("FREEZE_TEST") + result = await backend.layer_freeze("FREEZE_TEST") + assert result.get("ok") is True + result = await backend.layer_thaw("FREEZE_TEST") + assert result.get("ok") is True + + +async def test_layer_lock_unlock(backend): + await backend.layer_create("LOCK_TEST") + result = await backend.layer_lock("LOCK_TEST") + assert result.get("ok") is True + result = await backend.layer_unlock("LOCK_TEST") + assert result.get("ok") is True + + +async def test_layer_hide_show(backend): + await backend.layer_create("VIS_TEST") + result = await backend.layer_hide("VIS_TEST") + assert result.get("ok") is True + result = await backend.layer_show("VIS_TEST") + assert result.get("ok") is True + + +# --------------------------------------------------------------------------- +# Block tests +# --------------------------------------------------------------------------- + + +async def test_block_create_and_list(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + circle = await backend.entity_create_circle(5, 5, 3) + result = await backend.block_create_from_entities( + "MY_BLOCK", [line.handle, circle.handle], base_x=0, base_y=0 + ) + assert result.get("ok") is True + blocks = await backend.block_list() + block_names = [b.name for b in blocks] + assert "MY_BLOCK" in block_names + + +async def test_block_insert(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.block_create_from_entities("INS_BLOCK", [line.handle]) + result = await backend.block_insert("INS_BLOCK", 50, 50) + assert result.handle + assert result.type == "INSERT" + + +async def test_block_explode(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.block_create_from_entities("EXPLODE_BLK", [line.handle]) + ref = await backend.block_insert("EXPLODE_BLK", 0, 0) + result = await backend.block_explode(ref.handle) + assert result.get("ok") is True + + +async def test_block_get_attributes(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.block_create_from_entities("ATTR_BLK", [line.handle]) + ref = await backend.block_insert("ATTR_BLK", 0, 0) + attrs = await backend.block_get_attributes(ref.handle) + assert isinstance(attrs, dict) diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..9cf8f9f --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,231 @@ +"""Security tests for path validation and command sanitization.""" + +from __future__ import annotations + +import pytest +from fastmcp.exceptions import ToolError + +from security import sanitize_command, sanitize_lisp, validate_path + +# --------------------------------------------------------------------------- +# Path validation tests +# --------------------------------------------------------------------------- + + +class TestValidatePath: + """Tests for validate_path function.""" + + def test_valid_absolute_path(self, tmp_path): + """Valid absolute path should resolve correctly.""" + test_file = tmp_path / "drawing.dxf" + test_file.touch() + result = validate_path(str(test_file), allow_write=False) + assert result.exists() + + def test_empty_path_rejected(self): + """Empty path should raise ToolError.""" + with pytest.raises(ToolError, match="cannot be empty"): + validate_path("") + + def test_whitespace_path_rejected(self): + """Whitespace-only path should raise ToolError.""" + with pytest.raises(ToolError, match="cannot be empty"): + validate_path(" ") + + def test_traversal_dot_dot_slash(self): + """Path with ../ should be rejected.""" + with pytest.raises(ToolError, match="traversal"): + validate_path("../../../etc/passwd") + + def test_traversal_dot_dot_backslash(self): + """Path with ..\\ should be rejected.""" + with pytest.raises(ToolError, match="traversal"): + validate_path("..\\..\\Windows\\System32\\cmd.exe") + + def test_traversal_encoded(self): + """URL-encoded traversal should be rejected.""" + with pytest.raises(ToolError, match="traversal"): + validate_path("%2e%2e/secret") + + def test_null_byte(self): + """Null byte in path should be rejected.""" + with pytest.raises(ToolError, match="traversal"): + validate_path("file\x00.dxf") + + def test_write_requires_existing_parent(self, tmp_path): + """Write mode should check parent directory exists.""" + nonexistent = tmp_path / "nonexistent_dir" / "file.dxf" + with pytest.raises(ToolError, match="Parent directory"): + validate_path(str(nonexistent), allow_write=True) + + def test_write_with_valid_parent(self, tmp_path): + """Write mode should pass when parent exists.""" + target = tmp_path / "output.dxf" + result = validate_path(str(target), allow_write=True) + assert result.parent.exists() + + def test_allowed_paths_enforcement(self, tmp_path, monkeypatch): + """When ALLOWED_PATHS is set, paths outside should be rejected.""" + from config import Settings + + allowed_dir = tmp_path / "allowed" + allowed_dir.mkdir() + monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) + + import config + config.settings = Settings() + + blocked = tmp_path / "blocked" / "file.dxf" + blocked.parent.mkdir(exist_ok=True) + blocked.touch() + with pytest.raises(ToolError, match="not inside any allowed"): + validate_path(str(blocked)) + + config.settings = Settings.__new__(Settings) + config.settings.allowed_paths = [] + config.settings.dangerous_commands_enabled = False + monkeypatch.delenv("ALLOWED_PATHS", raising=False) + config.settings = Settings() + + def test_allowed_paths_permits_inside(self, tmp_path, monkeypatch): + """When ALLOWED_PATHS is set, paths inside should be allowed.""" + from config import Settings + + allowed_dir = tmp_path / "allowed" + allowed_dir.mkdir() + test_file = allowed_dir / "drawing.dxf" + test_file.touch() + monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) + + import config + config.settings = Settings() + + result = validate_path(str(test_file)) + assert result.exists() + + monkeypatch.delenv("ALLOWED_PATHS", raising=False) + config.settings = Settings() + + +# --------------------------------------------------------------------------- +# Command sanitization tests +# --------------------------------------------------------------------------- + + +class TestSanitizeCommand: + """Tests for sanitize_command function.""" + + def test_safe_command_passes(self): + """Safe commands should pass through.""" + assert sanitize_command("_ZOOM E") == "_ZOOM E" + + def test_safe_regen_command(self): + """REGEN command should pass.""" + assert sanitize_command("_REGEN") == "_REGEN" + + def test_empty_command_rejected(self): + """Empty command should raise ToolError.""" + with pytest.raises(ToolError, match="cannot be empty"): + sanitize_command("") + + def test_erase_command_blocked(self): + """ERASE command should be blocked.""" + with pytest.raises(ToolError, match="restricted command"): + sanitize_command("ERASE ALL") + + def test_delete_command_blocked(self): + """DELETE command should be blocked.""" + with pytest.raises(ToolError, match="restricted command"): + sanitize_command("DELETE") + + def test_shell_command_blocked(self): + """SHELL command should be blocked.""" + with pytest.raises(ToolError, match="restricted command"): + sanitize_command("SHELL") + + def test_quit_command_blocked(self): + """QUIT command should be blocked.""" + with pytest.raises(ToolError, match="restricted command"): + sanitize_command("QUIT") + + def test_script_command_blocked(self): + """SCRIPT command should be blocked.""" + with pytest.raises(ToolError, match="restricted command"): + sanitize_command("SCRIPT malicious.scr") + + def test_dangerous_enabled_bypass(self, monkeypatch): + """When DANGEROUS_COMMANDS_ENABLED=true, all commands pass.""" + from config import Settings + + monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") + + import config + config.settings = Settings() + + result = sanitize_command("ERASE ALL") + assert result == "ERASE ALL" + + monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False) + config.settings = Settings() + + +# --------------------------------------------------------------------------- +# LISP sanitization tests +# --------------------------------------------------------------------------- + + +class TestSanitizeLisp: + """Tests for sanitize_lisp function.""" + + def test_safe_lisp_passes(self): + """Safe LISP expressions should pass.""" + assert sanitize_lisp('(setvar "DIMSCALE" 1.0)') == '(setvar "DIMSCALE" 1.0)' + + def test_safe_arithmetic(self): + """Simple arithmetic LISP should pass.""" + assert sanitize_lisp("(+ 1 2 3)") == "(+ 1 2 3)" + + def test_empty_expression_rejected(self): + """Empty expression should raise ToolError.""" + with pytest.raises(ToolError, match="cannot be empty"): + sanitize_lisp("") + + def test_startapp_blocked(self): + """startapp should be blocked (arbitrary program execution).""" + with pytest.raises(ToolError, match="restricted function"): + sanitize_lisp('(startapp "cmd")') + + def test_command_function_blocked(self): + """(command ...) should be blocked.""" + with pytest.raises(ToolError, match="restricted function"): + sanitize_lisp('(command "ERASE" "ALL" "")') + + def test_file_delete_blocked(self): + """vl-file-delete should be blocked.""" + with pytest.raises(ToolError, match="restricted function"): + sanitize_lisp('(vl-file-delete "C:/important.dwg")') + + def test_load_blocked(self): + """(load ...) should be blocked.""" + with pytest.raises(ToolError, match="restricted function"): + sanitize_lisp('(load "malicious.lsp")') + + def test_eval_blocked(self): + """(eval ...) should be blocked.""" + with pytest.raises(ToolError, match="restricted function"): + sanitize_lisp("(eval (read user-input))") + + def test_dangerous_enabled_bypass(self, monkeypatch): + """When DANGEROUS_COMMANDS_ENABLED=true, all LISP passes.""" + from config import Settings + + monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") + + import config + config.settings = Settings() + + result = sanitize_lisp('(startapp "cmd")') + assert result == '(startapp "cmd")' + + monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False) + config.settings = Settings()