Initial commit

This commit is contained in:
U-C4N
2026-04-27 12:20:57 +03:00
commit 7b121ab416
49 changed files with 9904 additions and 0 deletions
+47
View File
@@ -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‘‘
+156
View File
@@ -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())
+19
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+42
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+57
View File
@@ -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
+100
View File
@@ -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
View File
@@ -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"]
+411
View File
@@ -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.
+15
View File
@@ -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",
]
+476
View File
@@ -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
+47
View File
@@ -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()
+442
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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
+2350
View File
File diff suppressed because it is too large Load Diff
View File
+15
View File
@@ -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()
+114
View File
@@ -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"
+128
View File
@@ -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
+40
View File
@@ -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"
+58
View File
@@ -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)
+100
View File
@@ -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
+203
View File
@@ -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
+113
View File
@@ -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)
+231
View File
@@ -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()