Coverage for excalidraw_mcp/mcp_tools.py: 99%
194 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-16 08:08 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-16 08:08 -0700
1"""MCP tool implementations for Excalidraw operations."""
3import logging
4from typing import Any
6from fastmcp import FastMCP
7from pydantic import BaseModel
9from .element_factory import ElementFactory
10from .http_client import http_client
11from .process_manager import process_manager
13logger = logging.getLogger(__name__)
16class MCPToolsManager:
17 """Manager for MCP tool implementations."""
19 def __init__(self, mcp: FastMCP) -> None:
20 self.mcp = mcp
21 self.element_factory = ElementFactory()
22 self._register_tools()
24 def _register_tools(self) -> None:
25 """Register all MCP tools."""
26 # Element management tools
27 self.mcp.tool("create_element")(self.create_element)
28 self.mcp.tool("update_element")(self.update_element)
29 self.mcp.tool("delete_element")(self.delete_element)
30 self.mcp.tool("query_elements")(self.query_elements)
32 # Batch operations
33 self.mcp.tool("batch_create_elements")(self.batch_create_elements)
35 # Element organization
36 self.mcp.tool("group_elements")(self.group_elements)
37 self.mcp.tool("ungroup_elements")(self.ungroup_elements)
38 self.mcp.tool("align_elements")(self.align_elements)
39 self.mcp.tool("distribute_elements")(self.distribute_elements)
40 self.mcp.tool("lock_elements")(self.lock_elements)
41 self.mcp.tool("unlock_elements")(self.unlock_elements)
43 # Resource access
44 self.mcp.tool("get_resource")(self.get_resource)
46 async def _ensure_canvas_available(self) -> bool:
47 """Ensure canvas server is available before operations."""
48 if not await process_manager.ensure_running():
49 raise RuntimeError("Canvas server is not available")
50 return True
52 async def _sync_to_canvas(
53 self, operation: str, data: dict[str, Any]
54 ) -> dict[str, Any] | None:
55 """Sync operation to canvas server with error handling."""
56 try:
57 await self._ensure_canvas_available()
59 if operation == "create":
60 return await http_client.post_json("/api/elements", data)
61 elif operation == "update":
62 element_id = data.pop("id")
63 return await http_client.put_json(f"/api/elements/{element_id}", data)
64 elif operation == "delete":
65 return {
66 "success": await http_client.delete(f"/api/elements/{data['id']}")
67 }
68 elif operation == "query":
69 return await http_client.get_json("/api/elements")
70 else:
71 logger.error(f"Unknown sync operation: {operation}")
72 return None
74 except Exception as e:
75 logger.error(f"Canvas sync failed for {operation}: {e}")
76 raise RuntimeError(f"Failed to sync {operation} to canvas: {e}")
78 # Element Management Tools
80 async def create_element(self, request: BaseModel) -> dict[str, Any]:
81 """Create a new element on the canvas."""
82 try:
83 # Create element with factory
84 element_data = self.element_factory.create_element(request.model_dump())
86 # Sync to canvas
87 result = await self._sync_to_canvas("create", element_data)
89 if result and result.get("success"):
90 return {
91 "success": True,
92 "element": result.get("element", element_data),
93 "message": f"Created {element_data['type']} element successfully",
94 }
95 else:
96 return {
97 "success": False,
98 "error": "Failed to create element on canvas",
99 "element_data": element_data,
100 }
102 except Exception as e:
103 logger.error(f"Element creation failed: {e}")
104 return {"success": False, "error": f"Element creation failed: {e}"}
106 async def update_element(self, request: BaseModel) -> dict[str, Any]:
107 """Update an existing element."""
108 try:
109 request_data = request.model_dump()
110 element_id = request_data.get("id")
112 if not element_id:
113 return {"success": False, "error": "Element ID is required for updates"}
115 # Prepare update data
116 update_data = self.element_factory.prepare_update_data(request_data)
118 # Sync to canvas
119 result = await self._sync_to_canvas("update", update_data)
121 if result and result.get("success"):
122 return {
123 "success": True,
124 "element": result.get("element"),
125 "message": f"Updated element {element_id} successfully",
126 }
127 else:
128 return {"success": False, "error": "Failed to update element on canvas"}
130 except Exception as e:
131 logger.error(f"Element update failed: {e}")
132 return {"success": False, "error": f"Element update failed: {e}"}
134 async def delete_element(self, element_id: str) -> dict[str, Any]:
135 """Delete an element from the canvas."""
136 try:
137 # Sync to canvas
138 result = await self._sync_to_canvas("delete", {"id": element_id})
140 if result and result.get("success"):
141 return {
142 "success": True,
143 "message": f"Deleted element {element_id} successfully",
144 }
145 else:
146 return {
147 "success": False,
148 "error": "Failed to delete element from canvas",
149 }
151 except Exception as e:
152 logger.error(f"Element deletion failed: {e}")
153 return {"success": False, "error": f"Element deletion failed: {e}"}
155 async def query_elements(self, request: BaseModel) -> dict[str, Any]:
156 """Query elements from the canvas."""
157 try:
158 # Sync to canvas
159 result = await self._sync_to_canvas("query", request.model_dump())
161 if result:
162 elements = result.get("elements", [])
163 return {
164 "success": True,
165 "elements": elements,
166 "count": len(elements),
167 "message": f"Retrieved {len(elements)} elements",
168 }
169 else:
170 return {
171 "success": False,
172 "error": "Failed to query elements from canvas",
173 }
175 except Exception as e:
176 logger.error(f"Element query failed: {e}")
177 return {"success": False, "error": f"Element query failed: {e}"}
179 # Batch Operations
181 async def batch_create_elements(self, request: BaseModel) -> dict[str, Any]:
182 """Create multiple elements in one operation."""
183 try:
184 request_data = request.model_dump()
185 elements_data = request_data.get("elements", [])
187 if not elements_data:
188 return {
189 "success": False,
190 "error": "No elements provided for batch creation",
191 }
193 # Limit batch size
194 max_batch_size = 50
195 if len(elements_data) > max_batch_size:
196 return {
197 "success": False,
198 "error": f"Batch size exceeds maximum limit of {max_batch_size}",
199 }
201 # Create elements with factory
202 created_elements = []
203 for element_data in elements_data:
204 created_element = self.element_factory.create_element(element_data)
205 created_elements.append(created_element)
207 # Sync to canvas
208 batch_data = {"elements": created_elements}
209 result = await http_client.post_json("/api/elements/batch", batch_data)
211 if result and result.get("success"):
212 return {
213 "success": True,
214 "elements": result.get("elements", created_elements),
215 "count": len(created_elements),
216 "message": f"Created {len(created_elements)} elements successfully",
217 }
218 else:
219 return {
220 "success": False,
221 "error": "Failed to create batch elements on canvas",
222 "created_data": created_elements,
223 }
225 except Exception as e:
226 logger.error(f"Batch element creation failed: {e}")
227 return {
228 "success": False,
229 "error": f"Batch element creation failed: {e}",
230 }
232 # Element Organization Tools
234 async def group_elements(self, element_ids: list[str]) -> dict[str, Any]:
235 """Group multiple elements together."""
236 try:
237 if len(element_ids) < 2:
238 return {
239 "success": False,
240 "error": "At least 2 elements required for grouping",
241 }
243 group_data = {"elementIds": element_ids}
244 result = await http_client.post_json("/api/elements/group", group_data)
246 if result and result.get("success"):
247 return {
248 "success": True,
249 "group_id": result.get("groupId"),
250 "message": f"Grouped {len(element_ids)} elements successfully",
251 }
252 else:
253 return {"success": False, "error": "Failed to group elements on canvas"}
255 except Exception as e:
256 logger.error(f"Element grouping failed: {e}")
257 return {"success": False, "error": f"Element grouping failed: {e}"}
259 async def ungroup_elements(self, group_id: str) -> dict[str, Any]:
260 """Ungroup a group of elements."""
261 try:
262 result = await http_client.delete(f"/api/elements/group/{group_id}")
264 if result:
265 return {
266 "success": True,
267 "message": f"Ungrouped elements from group {group_id} successfully",
268 }
269 else:
270 return {
271 "success": False,
272 "error": "Failed to ungroup elements on canvas",
273 }
275 except Exception as e:
276 logger.error(f"Element ungrouping failed: {e}")
277 return {"success": False, "error": f"Element ungrouping failed: {e}"}
279 async def align_elements(self, request: BaseModel) -> dict[str, Any]:
280 """Align elements to a specific position."""
281 try:
282 request_data = request.model_dump()
283 element_ids = request_data.get("elementIds", [])
284 alignment = request_data.get("alignment")
286 if not element_ids or not alignment:
287 return {
288 "success": False,
289 "error": "Element IDs and alignment are required",
290 }
292 align_data = {"elementIds": element_ids, "alignment": alignment}
293 result = await http_client.post_json("/api/elements/align", align_data)
295 if result and result.get("success"):
296 return {
297 "success": True,
298 "message": f"Aligned {len(element_ids)} elements to {alignment} successfully",
299 }
300 else:
301 return {"success": False, "error": "Failed to align elements on canvas"}
303 except Exception as e:
304 logger.error(f"Element alignment failed: {e}")
305 return {"success": False, "error": f"Element alignment failed: {e}"}
307 async def distribute_elements(self, request: BaseModel) -> dict[str, Any]:
308 """Distribute elements evenly."""
309 try:
310 request_data = request.model_dump()
311 element_ids = request_data.get("elementIds", [])
312 direction = request_data.get("direction")
314 if not element_ids or not direction:
315 return {
316 "success": False,
317 "error": "Element IDs and direction are required",
318 }
320 distribute_data = {"elementIds": element_ids, "direction": direction}
321 result = await http_client.post_json(
322 "/api/elements/distribute", distribute_data
323 )
325 if result and result.get("success"):
326 return {
327 "success": True,
328 "message": f"Distributed {len(element_ids)} elements {direction}ly successfully",
329 }
330 else:
331 return {
332 "success": False,
333 "error": "Failed to distribute elements on canvas",
334 }
336 except Exception as e:
337 logger.error(f"Element distribution failed: {e}")
338 return {"success": False, "error": f"Element distribution failed: {e}"}
340 async def lock_elements(self, element_ids: list[str]) -> dict[str, Any]:
341 """Lock elements to prevent modification."""
342 try:
343 lock_data = {"elementIds": element_ids, "locked": True}
344 result = await http_client.post_json("/api/elements/lock", lock_data)
346 if result and result.get("success"):
347 return {
348 "success": True,
349 "message": f"Locked {len(element_ids)} elements successfully",
350 }
351 else:
352 return {"success": False, "error": "Failed to lock elements on canvas"}
354 except Exception as e:
355 logger.error(f"Element locking failed: {e}")
356 return {"success": False, "error": f"Element locking failed: {e}"}
358 async def unlock_elements(self, element_ids: list[str]) -> dict[str, Any]:
359 """Unlock elements to allow modification."""
360 try:
361 unlock_data = {"elementIds": element_ids, "locked": False}
362 result = await http_client.post_json("/api/elements/lock", unlock_data)
364 if result and result.get("success"):
365 return {
366 "success": True,
367 "message": f"Unlocked {len(element_ids)} elements successfully",
368 }
369 else:
370 return {
371 "success": False,
372 "error": "Failed to unlock elements on canvas",
373 }
375 except Exception as e:
376 logger.error(f"Element unlocking failed: {e}")
377 return {"success": False, "error": f"Element unlocking failed: {e}"}
379 # Resource Access
381 async def get_resource(self, resource_type: str) -> dict[str, Any]:
382 """Get canvas resources (scene, library, theme, elements)."""
383 try:
384 valid_resources = ["scene", "library", "theme", "elements"]
386 if resource_type not in valid_resources:
387 return {
388 "success": False,
389 "error": f"Invalid resource type. Must be one of: {', '.join(valid_resources)}",
390 }
392 result = await http_client.get_json(f"/api/{resource_type}")
394 if result:
395 return {
396 "success": True,
397 "resource_type": resource_type,
398 "data": result,
399 "message": f"Retrieved {resource_type} resource successfully",
400 }
401 else:
402 return {
403 "success": False,
404 "error": f"Failed to retrieve {resource_type} resource from canvas",
405 }
407 except Exception as e:
408 logger.error(f"Resource retrieval failed: {e}")
409 return {"success": False, "error": f"Resource retrieval failed: {e}"}