Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,420 changes: 3,420 additions & 0 deletions demo-output/autocad-mcp-headless-demo.dxf

Large diffs are not rendered by default.

Binary file added demo-output/autocad-mcp-headless-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion src/autocad_mcp/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ class CommandResult:
def to_dict(self) -> dict:
d: dict[str, Any] = {"ok": self.ok}
if self.ok:
d["payload"] = self.payload
# Ensure payload is JSON-serializable; wrap non-dict payloads
if isinstance(self.payload, dict):
d["payload"] = self.payload
else:
# Wrap non-dict payloads in a dict for consistency
d["payload"] = {"result": self.payload} if self.payload is not None else None
else:
d["error"] = error_payload(
self.error,
Expand Down
13 changes: 13 additions & 0 deletions src/autocad_mcp/backends/ezdxf_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ async def status(self) -> CommandResult:
"capabilities": {k: v for k, v in self.capabilities.__dict__.items()},
})

async def shutdown(self) -> None:
"""Clean up backend resources."""
try:
# Close the screenshot provider to release matplotlib resources
if hasattr(self._screenshot, 'cleanup'):
self._screenshot.cleanup()
# Clear document reference
self._doc = None
self._msp = None
except Exception:
# Silently ignore cleanup errors
pass

def _next_id(self) -> str:
self._entity_counter += 1
return f"ezdxf_{self._entity_counter}"
Expand Down
15 changes: 14 additions & 1 deletion src/autocad_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,19 @@ def _screenshot_result(
"""Return screenshot metadata by default; inline image requires opt-in."""
if not result.ok or not result.payload:
return _format_result(result)
# Validate payload type is bytes or string (base64)
payload_type = type(result.payload)
if not isinstance(result.payload, (bytes, str, dict)):
return _json(
{
"ok": False,
"error": {
"message": f"Screenshot payload must be bytes, string, or dict, got {payload_type.__name__}",
"code": "E_SCREENSHOT_TYPE_ERROR",
},
"details": {"expected": "bytes|str|dict", "actual": payload_type.__name__},
}
)
try:
metadata, raw = _persist_screenshot(result.payload, stem=stem)
except Exception as exc:
Expand All @@ -587,7 +600,7 @@ def _screenshot_result(
if include_image and allow_inline and len(raw) <= max_inline:
metadata["inline"] = True
text = _json({"ok": True, "payload": payload})
if metadata["inline"]:
if metadata.get("inline", False):
return [
TextContent(type="text", text=text),
ImageContent(type="image", data=base64.b64encode(raw).decode("ascii"), mimeType="image/png"),
Expand Down
36 changes: 35 additions & 1 deletion src/autocad_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import shlex
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
Expand All @@ -26,7 +27,40 @@

# Paths
LISP_DIR = Path(__file__).resolve().parent.parent.parent / "lisp-code"
IPC_DIR = Path(os.environ.get("AUTOCAD_MCP_IPC_DIR", "C:/temp"))


def _initialize_ipc_dir() -> Path:
"""Initialize IPC directory with proper fallback and error handling."""
configured = os.environ.get("AUTOCAD_MCP_IPC_DIR", "").strip()
if configured:
ipc_path = Path(configured)
else:
# Use system temp directory as the default instead of hardcoded C:/temp
ipc_path = Path(tempfile.gettempdir())

try:
# Ensure directory exists and is writable
ipc_path.mkdir(parents=True, exist_ok=True)
# Test writability
test_file = ipc_path / ".autocad_mcp_test"
test_file.write_text("")
test_file.unlink()
return ipc_path
except (OSError, PermissionError) as e:
log.warning("ipc_dir_init_failed", path=str(ipc_path), error=str(e))
# Fallback to user's temp directory
fallback = Path(tempfile.gettempdir())
if fallback != ipc_path:
try:
fallback.mkdir(parents=True, exist_ok=True)
return fallback
except (OSError, PermissionError):
pass
# Last resort: use current working directory
return Path.cwd()


IPC_DIR = _initialize_ipc_dir()

# Backend selection
BACKEND_DEFAULT = "auto" # auto | file_ipc | ezdxf
Expand Down
36 changes: 33 additions & 3 deletions src/autocad_mcp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,13 @@ def infer_error_code(message: str | None) -> str:
return "E_VARIABLE_REJECTED"
if "validation" in text or "does not match" in text:
return "E_VALIDATION_FAILED"
if "plot failed" in text or "can not be set" in text:
return "E_INTERNAL"
if "plot" in text:
# More specific plot error detection
if "scale" in text:
return "E_PLOT_SCALE_MISMATCH"
if "page" in text or "media" in text or "paper" in text:
return "E_PLOT_PAGE_MISMATCH"
return "E_SYSTEM_CALL_FAILED" # Treat plot failures as system call issues
if "not supported" in text or "unknown" in text:
return "E_UNSUPPORTED_OPERATION"
return "E_INTERNAL"
Expand All @@ -94,8 +99,23 @@ def exception_context(
parameters: dict | None = None,
system_call: str | None = None,
file_path: str | None = None,
include_traceback: bool = False,
) -> tuple[str, dict]:
"""Describe an exception without reducing it to an opaque ``[Errno N]`` string."""
"""Describe an exception without reducing it to an opaque ``[Errno N]`` string.

Args:
exc: The exception to describe
operation: Operation that was attempted
parameters: Operation parameters (field names will be logged)
system_call: Name of system call or operation made
file_path: Associated file path if applicable
include_traceback: Whether to include traceback info (for debug only)

Returns:
Tuple of (message: str, details: dict)
"""
import traceback

errno = getattr(exc, "errno", None)
winerror = getattr(exc, "winerror", None)
strerror = getattr(exc, "strerror", None)
Expand All @@ -114,6 +134,16 @@ def exception_context(
"winerror": winerror,
"system_message": strerror,
}

# Add traceback info if requested (useful for diagnostic logs)
if include_traceback:
try:
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
details["traceback"] = "".join(tb_lines)
except Exception:
# Never let traceback collection fail the error reporting
pass

return message, {key: value for key, value in details.items() if value is not None}


Expand Down
12 changes: 11 additions & 1 deletion src/autocad_mcp/pid/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
"""P&ID tools and CTO block library."""
"""P&ID tools and CTO block library.

This module provides P&ID-specific AutoCAD operations and integration with the
CAD Tools Online (CTO) symbol library for industrial process flow diagrams.
"""

from __future__ import annotations

from autocad_mcp.pid.cto_library import CTO_CATEGORIES, CTO_ROOT

__all__ = ["CTO_CATEGORIES", "CTO_ROOT"]
10 changes: 10 additions & 0 deletions src/autocad_mcp/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ def doc(self, value: ezdxf.document.Drawing):
def capture(self) -> str | None:
return self.render()

def cleanup(self) -> None:
"""Clean up matplotlib resources."""
try:
import matplotlib.pyplot as plt
plt.close('all')
except Exception:
# Silently ignore cleanup errors
pass
self._doc = None

def render(self, dpi: int = 150, background: str = "white") -> str | None:
if self._doc is None:
return None
Expand Down
9 changes: 6 additions & 3 deletions src/autocad_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,10 @@ async def _attach_document_context(
{
"session_id": context.payload.get("session_id"),
"worker_generation": context.payload.get("worker_generation"),
"doc_id": context.payload["doc_id"],
"active_doc_id": context.payload["active_doc_id"],
"doc_id": context.payload.get("doc_id", ""),
"active_doc_id": context.payload.get("active_doc_id", ""),
"active_path": context.payload.get("active_path"),
"revision": context.payload["revision"],
"revision": context.payload.get("revision", 0),
"lease_token": context.payload.get("lease_token"),
}
)
Expand Down Expand Up @@ -462,6 +462,9 @@ async def _compensate_product_failure(
erased,
reason="product_create_failure_compensation",
)
# Ensure registry is a proper dict (validation of mark_feature_handles_invalid return)
if not isinstance(registry, dict):
registry = {"invalidated": [], "stale_count": 0, "product_state_revision": 0}
return {
"attempted": True,
"complete": not failed and not missing_preexisting,
Expand Down
33 changes: 33 additions & 0 deletions src/autocad_mcp/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,39 @@ def _append_utf8_bom_log(path: Path, event: str, **data: Any) -> None:
def _process_alive(process_id: int | None) -> bool:
if not process_id or process_id <= 0:
return False
if sys.platform == "win32":
import ctypes
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
open_process = kernel32.OpenProcess
open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
open_process.restype = wintypes.HANDLE
handle = open_process(0x00100000, False, process_id)
if not handle:
error_code = ctypes.get_last_error()
if error_code == 5:
return True
if error_code == 87:
return False
raise ctypes.WinError(error_code)

try:
wait_for_single_object = kernel32.WaitForSingleObject
wait_for_single_object.argtypes = (wintypes.HANDLE, wintypes.DWORD)
wait_for_single_object.restype = wintypes.DWORD
close_handle = kernel32.CloseHandle
close_handle.argtypes = (wintypes.HANDLE,)
close_handle.restype = wintypes.BOOL
wait_result = wait_for_single_object(handle, 0)
if wait_result == 0x00000102:
return True
if wait_result == 0:
return False
raise ctypes.WinError(ctypes.get_last_error())
finally:
close_handle(handle)

try:
os.kill(process_id, 0)
return True
Expand Down
17 changes: 17 additions & 0 deletions tests/test_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

import codecs
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace

import pytest

import autocad_mcp.supervisor as supervisor_module
from autocad_mcp.supervisor import (
DesktopSupervisor,
SupervisorConfig,
Expand All @@ -13,6 +18,18 @@
)


@pytest.mark.skipif(
sys.platform != "win32", reason="Windows process probing uses Win32 handles"
)
def test_process_alive_does_not_send_console_signal_on_windows(monkeypatch):
def unexpected_signal(*_args):
pytest.fail("Windows process checks must not use os.kill")

monkeypatch.setattr(supervisor_module.os, "kill", unexpected_signal)

assert supervisor_module._process_alive(os.getpid())


def test_supervisor_log_is_utf8_bom_and_preserves_chinese(tmp_path):
path = tmp_path / "supervisor.log"

Expand Down