meshwire 0.5.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """MCP server for Blender - Control Blender via Model Context Protocol."""
2
+
3
+ __version__ = "0.5.0a1"
@@ -0,0 +1,248 @@
1
+ """Async TCP client for communicating with the Meshwire addon."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger("meshwire.client")
10
+
11
+
12
+ class BlenderConnectionError(Exception):
13
+ """Raised when connection to Blender fails."""
14
+
15
+ pass
16
+
17
+
18
+ class BlenderCommandError(Exception):
19
+ """Raised when a Blender command fails."""
20
+
21
+ pass
22
+
23
+
24
+ class BlenderClient:
25
+ """
26
+ Async TCP client for communicating with the Meshwire addon.
27
+
28
+ Uses JSON-RPC 2.0 protocol over TCP with newline-delimited messages.
29
+ """
30
+
31
+ def __init__(self, host: str = "127.0.0.1", port: int = 9876):
32
+ self.host = host
33
+ self.port = port
34
+ self._reader: asyncio.StreamReader | None = None
35
+ self._writer: asyncio.StreamWriter | None = None
36
+ self._lock = asyncio.Lock()
37
+ self._connected = False
38
+ self._pending_requests: dict[str, asyncio.Future] = {}
39
+ self._read_task: asyncio.Task | None = None
40
+
41
+ @property
42
+ def connected(self) -> bool:
43
+ """Check if client is connected."""
44
+ return self._connected and self._writer is not None
45
+
46
+ async def connect(self, timeout: float = 5.0) -> None:
47
+ """
48
+ Connect to the Blender addon socket server.
49
+
50
+ Args:
51
+ timeout: Connection timeout in seconds
52
+
53
+ Raises:
54
+ BlenderConnectionError: If connection fails
55
+ """
56
+ if self._connected:
57
+ return
58
+
59
+ try:
60
+ self._reader, self._writer = await asyncio.wait_for(
61
+ asyncio.open_connection(self.host, self.port),
62
+ timeout=timeout,
63
+ )
64
+ self._connected = True
65
+ logger.info(f"Connected to Blender at {self.host}:{self.port}")
66
+
67
+ # Start background reader task
68
+ self._read_task = asyncio.create_task(self._read_loop())
69
+
70
+ except asyncio.TimeoutError:
71
+ raise BlenderConnectionError(
72
+ f"Connection to Blender timed out ({self.host}:{self.port})"
73
+ )
74
+ except ConnectionRefusedError:
75
+ raise BlenderConnectionError(
76
+ f"Connection refused. Is Blender running with MCP addon enabled? ({self.host}:{self.port})"
77
+ )
78
+ except OSError as e:
79
+ raise BlenderConnectionError(f"Connection failed: {e}")
80
+
81
+ async def disconnect(self) -> None:
82
+ """Disconnect from Blender."""
83
+ self._connected = False
84
+
85
+ if self._read_task:
86
+ self._read_task.cancel()
87
+ try:
88
+ await self._read_task
89
+ except asyncio.CancelledError:
90
+ pass
91
+ self._read_task = None
92
+
93
+ if self._writer:
94
+ self._writer.close()
95
+ try:
96
+ await self._writer.wait_closed()
97
+ except Exception:
98
+ pass
99
+ self._writer = None
100
+ self._reader = None
101
+
102
+ # Cancel any pending requests
103
+ for future in self._pending_requests.values():
104
+ if not future.done():
105
+ future.set_exception(BlenderConnectionError("Disconnected"))
106
+ self._pending_requests.clear()
107
+
108
+ logger.info("Disconnected from Blender")
109
+
110
+ async def send_command(
111
+ self,
112
+ method: str,
113
+ params: dict[str, Any] | None = None,
114
+ timeout: float = 30.0,
115
+ ) -> Any:
116
+ """
117
+ Send a command to Blender and wait for the response.
118
+
119
+ Args:
120
+ method: The command method name
121
+ params: Command parameters
122
+ timeout: Response timeout in seconds
123
+
124
+ Returns:
125
+ The result from Blender
126
+
127
+ Raises:
128
+ BlenderConnectionError: If not connected
129
+ BlenderCommandError: If the command fails
130
+ asyncio.TimeoutError: If response times out
131
+ """
132
+ if not self._connected:
133
+ await self.connect()
134
+
135
+ if not self._writer:
136
+ raise BlenderConnectionError("Not connected to Blender")
137
+
138
+ # Generate unique request ID
139
+ request_id = str(uuid.uuid4())
140
+
141
+ # Build JSON-RPC request
142
+ request = {
143
+ "jsonrpc": "2.0",
144
+ "method": method,
145
+ "params": params or {},
146
+ "id": request_id,
147
+ }
148
+
149
+ # Create future for response
150
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
151
+ self._pending_requests[request_id] = future
152
+
153
+ try:
154
+ # Send request
155
+ async with self._lock:
156
+ message = json.dumps(request) + "\n"
157
+ self._writer.write(message.encode("utf-8"))
158
+ await self._writer.drain()
159
+
160
+ logger.debug(f"Sent command: {method}")
161
+
162
+ # Wait for response
163
+ result = await asyncio.wait_for(future, timeout=timeout)
164
+ return result
165
+
166
+ except asyncio.TimeoutError:
167
+ self._pending_requests.pop(request_id, None)
168
+ raise asyncio.TimeoutError(f"Command '{method}' timed out after {timeout}s")
169
+
170
+ except Exception:
171
+ self._pending_requests.pop(request_id, None)
172
+ raise
173
+
174
+ async def _read_loop(self) -> None:
175
+ """Background task to read responses from Blender."""
176
+ buffer = b""
177
+
178
+ while self._connected and self._reader:
179
+ try:
180
+ # Read data
181
+ data = await self._reader.read(65536)
182
+ if not data:
183
+ # Connection closed
184
+ logger.warning("Connection closed by Blender")
185
+ await self.disconnect()
186
+ break
187
+
188
+ buffer += data
189
+
190
+ # Process complete messages
191
+ while b"\n" in buffer:
192
+ line, buffer = buffer.split(b"\n", 1)
193
+ if line.strip():
194
+ await self._handle_response(line)
195
+
196
+ except asyncio.CancelledError:
197
+ break
198
+ except Exception as e:
199
+ logger.error(f"Read error: {e}")
200
+ await self.disconnect()
201
+ break
202
+
203
+ async def _handle_response(self, data: bytes) -> None:
204
+ """Handle a JSON-RPC response from Blender."""
205
+ try:
206
+ response = json.loads(data.decode("utf-8"))
207
+ except json.JSONDecodeError as e:
208
+ logger.error(f"Invalid JSON response: {e}")
209
+ return
210
+
211
+ request_id = response.get("id")
212
+ if request_id is None:
213
+ logger.warning("Received response without ID")
214
+ return
215
+
216
+ future = self._pending_requests.pop(request_id, None)
217
+ if future is None:
218
+ logger.warning(f"Received response for unknown request: {request_id}")
219
+ return
220
+
221
+ if "error" in response:
222
+ error = response["error"]
223
+ error_msg = error.get("message", str(error))
224
+ future.set_exception(BlenderCommandError(error_msg))
225
+ else:
226
+ future.set_result(response.get("result"))
227
+
228
+ async def ping(self, timeout: float = 5.0) -> bool:
229
+ """
230
+ Test connectivity to Blender.
231
+
232
+ Returns:
233
+ True if connected and responsive
234
+ """
235
+ try:
236
+ result = await self.send_command("ping", timeout=timeout)
237
+ return result.get("pong", False)
238
+ except Exception:
239
+ return False
240
+
241
+ async def __aenter__(self) -> "BlenderClient":
242
+ """Async context manager entry."""
243
+ await self.connect()
244
+ return self
245
+
246
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
247
+ """Async context manager exit."""
248
+ await self.disconnect()
mcp_blender/py.typed ADDED
File without changes