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

1"""MCP tool implementations for Excalidraw operations.""" 

2 

3import logging 

4from typing import Any 

5 

6from fastmcp import FastMCP 

7from pydantic import BaseModel 

8 

9from .element_factory import ElementFactory 

10from .http_client import http_client 

11from .process_manager import process_manager 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16class MCPToolsManager: 

17 """Manager for MCP tool implementations.""" 

18 

19 def __init__(self, mcp: FastMCP) -> None: 

20 self.mcp = mcp 

21 self.element_factory = ElementFactory() 

22 self._register_tools() 

23 

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) 

31 

32 # Batch operations 

33 self.mcp.tool("batch_create_elements")(self.batch_create_elements) 

34 

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) 

42 

43 # Resource access 

44 self.mcp.tool("get_resource")(self.get_resource) 

45 

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 

51 

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() 

58 

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 

73 

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}") 

77 

78 # Element Management Tools 

79 

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()) 

85 

86 # Sync to canvas 

87 result = await self._sync_to_canvas("create", element_data) 

88 

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 } 

101 

102 except Exception as e: 

103 logger.error(f"Element creation failed: {e}") 

104 return {"success": False, "error": f"Element creation failed: {e}"} 

105 

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") 

111 

112 if not element_id: 

113 return {"success": False, "error": "Element ID is required for updates"} 

114 

115 # Prepare update data 

116 update_data = self.element_factory.prepare_update_data(request_data) 

117 

118 # Sync to canvas 

119 result = await self._sync_to_canvas("update", update_data) 

120 

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"} 

129 

130 except Exception as e: 

131 logger.error(f"Element update failed: {e}") 

132 return {"success": False, "error": f"Element update failed: {e}"} 

133 

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}) 

139 

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 } 

150 

151 except Exception as e: 

152 logger.error(f"Element deletion failed: {e}") 

153 return {"success": False, "error": f"Element deletion failed: {e}"} 

154 

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()) 

160 

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 } 

174 

175 except Exception as e: 

176 logger.error(f"Element query failed: {e}") 

177 return {"success": False, "error": f"Element query failed: {e}"} 

178 

179 # Batch Operations 

180 

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", []) 

186 

187 if not elements_data: 

188 return { 

189 "success": False, 

190 "error": "No elements provided for batch creation", 

191 } 

192 

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 } 

200 

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) 

206 

207 # Sync to canvas 

208 batch_data = {"elements": created_elements} 

209 result = await http_client.post_json("/api/elements/batch", batch_data) 

210 

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 } 

224 

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 } 

231 

232 # Element Organization Tools 

233 

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 } 

242 

243 group_data = {"elementIds": element_ids} 

244 result = await http_client.post_json("/api/elements/group", group_data) 

245 

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"} 

254 

255 except Exception as e: 

256 logger.error(f"Element grouping failed: {e}") 

257 return {"success": False, "error": f"Element grouping failed: {e}"} 

258 

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}") 

263 

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 } 

274 

275 except Exception as e: 

276 logger.error(f"Element ungrouping failed: {e}") 

277 return {"success": False, "error": f"Element ungrouping failed: {e}"} 

278 

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") 

285 

286 if not element_ids or not alignment: 

287 return { 

288 "success": False, 

289 "error": "Element IDs and alignment are required", 

290 } 

291 

292 align_data = {"elementIds": element_ids, "alignment": alignment} 

293 result = await http_client.post_json("/api/elements/align", align_data) 

294 

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"} 

302 

303 except Exception as e: 

304 logger.error(f"Element alignment failed: {e}") 

305 return {"success": False, "error": f"Element alignment failed: {e}"} 

306 

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") 

313 

314 if not element_ids or not direction: 

315 return { 

316 "success": False, 

317 "error": "Element IDs and direction are required", 

318 } 

319 

320 distribute_data = {"elementIds": element_ids, "direction": direction} 

321 result = await http_client.post_json( 

322 "/api/elements/distribute", distribute_data 

323 ) 

324 

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 } 

335 

336 except Exception as e: 

337 logger.error(f"Element distribution failed: {e}") 

338 return {"success": False, "error": f"Element distribution failed: {e}"} 

339 

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) 

345 

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"} 

353 

354 except Exception as e: 

355 logger.error(f"Element locking failed: {e}") 

356 return {"success": False, "error": f"Element locking failed: {e}"} 

357 

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) 

363 

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 } 

374 

375 except Exception as e: 

376 logger.error(f"Element unlocking failed: {e}") 

377 return {"success": False, "error": f"Element unlocking failed: {e}"} 

378 

379 # Resource Access 

380 

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"] 

385 

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 } 

391 

392 result = await http_client.get_json(f"/api/{resource_type}") 

393 

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 } 

406 

407 except Exception as e: 

408 logger.error(f"Resource retrieval failed: {e}") 

409 return {"success": False, "error": f"Resource retrieval failed: {e}"}