Initial commit
This commit is contained in:
@@ -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‘‘
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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/`
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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}`.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
@@ -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}`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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
|
||||
+41
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+20
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+227
@@ -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"(?:(?<![\w-])|^)(?:" + "|".join(_DANGEROUS_LISP_PATTERNS) + r")(?![\w-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_command(command: str) -> 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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user