meshwire 0.5.0__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.0"
@@ -0,0 +1,685 @@
1
+ """Async TCP client for communicating with the Meshwire addon."""
2
+
3
+ import asyncio
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import os
8
+ import socket
9
+ import uuid
10
+ from typing import Any
11
+
12
+ from . import __version__, wire
13
+
14
+ logger = logging.getLogger("meshwire.client")
15
+
16
+ # The add-on holds at most 256 MiB of reply per client, so no honest reply line is longer.
17
+ MAX_REPLY_LINE = 256 * 1024 * 1024 + 1024 * 1024
18
+ READ_SIZE = 1024 * 1024
19
+ LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
20
+
21
+
22
+ class BlenderConnectionError(Exception):
23
+ """Raised when connection to Blender fails."""
24
+
25
+ pass
26
+
27
+
28
+ class _NotMeshwire(BlenderConnectionError):
29
+ """The peer showed it is not a Meshwire add-on holding our token.
30
+
31
+ The only failure discovery moves past: refused, closed before any reply byte, a reply
32
+ that is not a JSON object, or a missing or wrong add-on proof. Anything else may be
33
+ the right Blender, slow or full, and stops the search.
34
+ """
35
+
36
+
37
+ class _Unproven(BlenderConnectionError):
38
+ """A stop caused by a peer that has not proved it is a Meshwire add-on.
39
+
40
+ It may be a Blender that is busy, full or outdated, or a program squatting on a stale
41
+ port. The message calls it "the program", and names the instance file and its process
42
+ ID so that a stale file can be told from a busy Blender.
43
+ """
44
+
45
+
46
+ class _NoAnswer(_Unproven):
47
+ """A connect or handshake step ran out of time."""
48
+
49
+ def __init__(self, message: str, seconds: float):
50
+ super().__init__(message)
51
+ self.seconds = seconds
52
+
53
+
54
+ class _ClosedEarly(_Unproven):
55
+ """Accepted, then closed or reset before any reply byte: an eviction or a full server."""
56
+
57
+
58
+ class BlenderCommandError(Exception):
59
+ """Raised when a Blender command fails."""
60
+
61
+ pass
62
+
63
+
64
+ class BlenderClient:
65
+ """
66
+ Async TCP client for communicating with the Meshwire addon.
67
+
68
+ Uses JSON-RPC 2.0 protocol over TCP with newline-delimited messages.
69
+ """
70
+
71
+ def __init__(self, host: str = "127.0.0.1", port: int | None = None):
72
+ """``port=None`` finds the Blender to use on every connect (see ``_open``)."""
73
+ self.host = host
74
+ self._fixed_port = port
75
+ self.port = port if port is not None else wire.PORT_FIRST # the port in use now
76
+ self._reader: asyncio.StreamReader | None = None
77
+ self._writer: asyncio.StreamWriter | None = None
78
+ self._lock = asyncio.Lock()
79
+ self._connect_lock = asyncio.Lock()
80
+ self._connected = False
81
+ self._pending_requests: dict[str, asyncio.Future] = {}
82
+ self._read_task: asyncio.Task | None = None
83
+ self._epoch = 0 # bumped by disconnect(); an _open() that straddles one is void
84
+
85
+ @property
86
+ def connected(self) -> bool:
87
+ """Check if client is connected."""
88
+ return self._connected and self._writer is not None
89
+
90
+ async def connect(self, timeout: float = 5.0) -> None:
91
+ """
92
+ Connect to the Blender addon socket server.
93
+
94
+ Args:
95
+ timeout: Connection timeout in seconds
96
+
97
+ Raises:
98
+ BlenderConnectionError: If connection fails
99
+ """
100
+ if self._connected:
101
+ return
102
+
103
+ # Concurrent callers queue here; the ones after the first find it connected.
104
+ async with self._connect_lock:
105
+ if self._connected:
106
+ return
107
+ await self._open(timeout)
108
+
109
+ async def _discover(self) -> list[dict]:
110
+ """Live instances to try, newest first. Empty when the port is fixed or the host remote.
111
+
112
+ Blender can start, restart or move after this server did, so this runs at every
113
+ open. Discovery makes blocking probes, so it runs off the event loop.
114
+ """
115
+ if self._fixed_port is not None or self.host.lower() not in LOOPBACK_HOSTS:
116
+ return []
117
+ return await asyncio.to_thread(wire.live_instances)
118
+
119
+ @staticmethod
120
+ def _log_pick(pick: dict, total: int) -> None:
121
+ more = f", the most recently started of {total}" if total > 1 else ""
122
+ logger.info(
123
+ "Using the Blender on port %s (Blender %r, file %r)%s",
124
+ pick["port"],
125
+ pick["blender"],
126
+ pick["blend"] or "unsaved",
127
+ more,
128
+ )
129
+
130
+ async def _open(self, timeout: float) -> None:
131
+ """Connect, run the handshake and only then become connected.
132
+
133
+ The token is read afresh on every connect, so a token that changed since the
134
+ last connection is noticed here. The reader and writer stay local until the
135
+ handshake is done and no disconnect() has intervened; they are installed
136
+ together, with no await after the last check, so a disconnect can never leave
137
+ _connected True over a closed writer, or an open socket nobody owns.
138
+
139
+ With no fixed port, each live instance is tried in turn, newest first, with the
140
+ full handshake, until one accepts. A port that is live but not Meshwire (a crashed
141
+ Blender's port taken by another program) so cannot shadow a healthy older Blender.
142
+ """
143
+ try:
144
+ token = wire.read_token()
145
+ except OSError as e:
146
+ raise BlenderConnectionError(str(e)) from None
147
+ if not token:
148
+ raise BlenderConnectionError(
149
+ f"No Meshwire token at {wire.token_path()}. Start the server in Blender's "
150
+ "Meshwire tab, which writes it there. If Blender keeps its token elsewhere "
151
+ "(MESHWIRE_TOKEN set only in Blender, or a sandboxed Blender such as "
152
+ "Flatpak), use Copy token in the Meshwire tab and set MESHWIRE_TOKEN for "
153
+ "the MCP client."
154
+ )
155
+ epoch = self._epoch
156
+ if self._fixed_port is not None:
157
+ self.port = self._fixed_port
158
+ await self._connect_to(token, timeout, epoch)
159
+ return
160
+ found = await self._discover()
161
+ if epoch != self._epoch:
162
+ raise BlenderConnectionError("Disconnected")
163
+ if not found:
164
+ self.port = wire.PORT_FIRST
165
+ await self._connect_to(token, timeout, epoch)
166
+ return
167
+ failures = []
168
+ for position, instance in enumerate(found):
169
+ self.port = instance["port"]
170
+ if position == 0:
171
+ self._log_pick(instance, len(found))
172
+ else:
173
+ logger.info("Trying the next Blender, on port %s", instance["port"])
174
+ try:
175
+ await self._connect_to(token, timeout, epoch)
176
+ return
177
+ except _NotMeshwire as e:
178
+ if epoch != self._epoch:
179
+ raise
180
+ reason = str(e).splitlines()[0] if str(e) else type(e).__name__
181
+ failures.append((instance, reason))
182
+ except BlenderConnectionError as e:
183
+ if epoch != self._epoch:
184
+ raise
185
+ raise self._stopped_at(instance, position, e, timeout) from None
186
+ lines = [
187
+ f" port {i['port']} ({self._instance_file(i)}): {reason}" for i, reason in failures
188
+ ]
189
+ raise BlenderConnectionError(
190
+ "Could not connect to any of the Meshwire instances found:\n"
191
+ + "\n".join(lines)
192
+ + "\nA file here may be stale, or another program may hold that port. "
193
+ "Pass --port or set MESHWIRE_PORT to pick the Blender to use."
194
+ )
195
+
196
+ @staticmethod
197
+ def _instance_file(instance: dict) -> Any:
198
+ return wire.instances_dir() / f"{instance['port']}.json"
199
+
200
+ def _stopped_at(
201
+ self, instance: dict, position: int, error: BlenderConnectionError, timeout: float
202
+ ) -> BlenderConnectionError:
203
+ """The one error for an instance that may be a real Meshwire: no switching to another."""
204
+ port, file = instance["port"], self._instance_file(instance)
205
+ way_out = "pass --port or set MESHWIRE_PORT to pick one"
206
+ if not isinstance(error, _Unproven):
207
+ # The add-on proved itself, so this is a genuine Blender's own answer.
208
+ return BlenderConnectionError(
209
+ f"{error}\nThis came from the Blender on port {port} (instance file {file}). "
210
+ f"Not trying another Blender, because this one is the right one. You can {way_out}."
211
+ )
212
+ stale = (
213
+ f"If no Blender with process ID {instance['pid']} is running, that file is stale: "
214
+ "delete it."
215
+ )
216
+ where = f"The program on port {port} (instance file {file})"
217
+ if isinstance(error, _NoAnswer):
218
+ return BlenderConnectionError(
219
+ f"{where} did not answer within {error.seconds:g} s; if it is a Blender, it may "
220
+ f"be busy. Try again, or {way_out}. {stale}"
221
+ )
222
+ if isinstance(error, _ClosedEarly):
223
+ return BlenderConnectionError(
224
+ f"{where} closed the connection before answering. A busy or full Meshwire "
225
+ f"add-on does this. Try again, or {way_out}. {stale}"
226
+ )
227
+ return BlenderConnectionError(
228
+ f"{error}\nThis came from {where[0].lower() + where[1:]}. Not trying another "
229
+ f"Blender, because this one may be the right one. You can {way_out}. {stale}"
230
+ )
231
+
232
+ async def _connect_to(self, token: str, timeout: float, epoch: int) -> None:
233
+ """One connection attempt at ``self.port``: connect, handshake, install."""
234
+
235
+ def voided() -> None:
236
+ """Stop at once if a disconnect() ran while this open was waiting."""
237
+ if epoch != self._epoch:
238
+ raise BlenderConnectionError("Disconnected")
239
+
240
+ writer: asyncio.StreamWriter | None = None
241
+ try:
242
+ reader, writer = await asyncio.wait_for(
243
+ asyncio.open_connection(self.host, self.port),
244
+ timeout=timeout,
245
+ )
246
+ voided()
247
+ await self._hello(reader, writer, token, timeout, voided)
248
+ voided()
249
+ self._reader, self._writer = reader, writer
250
+ self._connected = True
251
+ logger.info(f"Connected to Blender at {self.host}:{self.port}")
252
+ self._read_task = asyncio.create_task(self._read_loop())
253
+ except BaseException as exc:
254
+ if writer is not None:
255
+ writer.close() # whatever failed, this socket is ours to close
256
+ if isinstance(exc, asyncio.TimeoutError):
257
+ raise _NoAnswer(
258
+ f"Connection to Blender timed out ({self.host}:{self.port})", timeout
259
+ ) from None
260
+ if isinstance(exc, ConnectionRefusedError):
261
+ raise _NotMeshwire(
262
+ f"Connection refused. Is Blender running with MCP addon enabled? ({self.host}:{self.port})"
263
+ ) from None
264
+ if isinstance(exc, OSError):
265
+ raise BlenderConnectionError(f"Connection failed: {exc}") from None
266
+ raise
267
+
268
+ def _token_help(self) -> str:
269
+ text = (
270
+ f"The token must match the one in Blender's Meshwire tab (Copy token); "
271
+ f"this server reads {wire.token_path()}."
272
+ )
273
+ text += (
274
+ " MESHWIRE_TOKEN, when set, overrides that file"
275
+ + (" (it is set here)" if os.environ.get("MESHWIRE_TOKEN", "").strip() else "")
276
+ + ". If the token or MESHWIRE_TOKEN changed, restart the MCP client."
277
+ )
278
+ return text
279
+
280
+ async def _send(self, writer: asyncio.StreamWriter, message: dict, timeout: float) -> None:
281
+ writer.write((json.dumps(message) + "\n").encode("utf-8"))
282
+ await asyncio.wait_for(writer.drain(), timeout=timeout)
283
+
284
+ async def _read_reply(
285
+ self,
286
+ reader: asyncio.StreamReader,
287
+ timeout: float,
288
+ peer: str,
289
+ verified: bool = False,
290
+ ) -> dict:
291
+ """One reply line of the handshake, as a dict.
292
+
293
+ Until the add-on has proved it holds the token (``verified``), nothing the peer
294
+ wrote is shown to the user or the model: its errors become fixed text built from
295
+ codes and numbers only.
296
+ """
297
+ # Before the add-on has proved itself, a bad reply shows it is not ours.
298
+ unproven = BlenderConnectionError if verified else _NotMeshwire
299
+ try:
300
+ line = await asyncio.wait_for(reader.readline(), timeout=timeout)
301
+ except (ValueError, asyncio.LimitOverrunError):
302
+ raise unproven(f"The program on {peer} did not complete the Meshwire handshake.")
303
+ except (ConnectionError, asyncio.IncompleteReadError):
304
+ line = b""
305
+ if not line.strip():
306
+ closed = BlenderConnectionError if verified else _ClosedEarly
307
+ raise closed(
308
+ "Blender closed the connection during the handshake. "
309
+ f"Is this a Meshwire add-on? ({self.host}:{self.port})"
310
+ )
311
+ try:
312
+ reply = json.loads(line.decode("utf-8"))
313
+ except (ValueError, RecursionError): # not JSON, or nested past the parser's limit
314
+ reply = None
315
+ if not isinstance(reply, dict):
316
+ raise unproven(f"The program on {peer} did not complete the Meshwire handshake.")
317
+ if reply.get("error") is not None:
318
+ raise self._hello_error(reply["error"], peer, verified)
319
+ return reply
320
+
321
+ async def _hello(
322
+ self,
323
+ reader: asyncio.StreamReader,
324
+ writer: asyncio.StreamWriter,
325
+ token: str,
326
+ timeout: float,
327
+ voided=lambda: None,
328
+ ) -> None:
329
+ """Mutual proof of the token, bound to the address reached. The token never goes out.
330
+
331
+ hello carries a nonce. The add-on answers with its own nonce, the address its
332
+ socket saw the bytes arrive at, and a proof over all three that it holds the
333
+ token. Only if that checks out, and the address is the one dialed, does this side
334
+ send its own proof. Anything else listening learns nothing and gets no command.
335
+ """
336
+ peername = writer.get_extra_info("peername") or (self.host, self.port)
337
+ peer = wire.endpoint(str(peername[0]), int(peername[1]))
338
+ nonce_c = wire.new_nonce()
339
+ request = wire.hello_request(nonce_c, str(uuid.uuid4()), client=f"meshwire {__version__}")
340
+ await self._send(writer, request, timeout)
341
+ voided()
342
+ reply = await self._read_reply(reader, timeout, peer)
343
+ voided()
344
+ result = reply.get("result")
345
+ result = result if isinstance(result, dict) else {}
346
+
347
+ theirs = result.get("protocol")
348
+ if type(theirs) is not int: # no protocol number: whatever answered is not our add-on
349
+ raise _NotMeshwire(str(self._protocol_error(theirs)))
350
+ if theirs != wire.PROTOCOL:
351
+ raise self._protocol_error(theirs)
352
+ nonce_s, seen, proof_a = result.get("nonce"), result.get("seen"), result.get("proof")
353
+ well_formed = (
354
+ isinstance(nonce_s, str)
355
+ and 16 <= len(nonce_s) <= 128
356
+ and all(c.isascii() and (c.isalnum() or c in "_-") for c in nonce_s)
357
+ and isinstance(seen, str)
358
+ and 0 < len(seen) <= 200
359
+ and isinstance(proof_a, str)
360
+ and len(proof_a) == 64
361
+ and all(c in "0123456789abcdef" for c in proof_a)
362
+ )
363
+ expected = (
364
+ wire.proof(token, wire.ADDON_LABEL, nonce_c, nonce_s, seen) if well_formed else ""
365
+ )
366
+ if not well_formed or not hmac.compare_digest(
367
+ proof_a.encode("utf-8"), expected.encode("utf-8")
368
+ ):
369
+ # Not the add-on that made this token. Say nothing more to it.
370
+ raise _NotMeshwire(
371
+ f"The program answering on {self.host}:{self.port} did not prove it holds the "
372
+ "Meshwire token, so nothing was sent to it. "
373
+ f"{self._token_help()} Another program may be using port {self.port}."
374
+ )
375
+ # The proof is genuine, so `seen` is where the real add-on received the bytes.
376
+ # It must be where this connection went, or something is relaying it.
377
+ override = os.environ.get("MESHWIRE_ADDON_ADDRESS", "").strip()
378
+ voided()
379
+ if override:
380
+ endpoints = await self._override_endpoints(override, timeout)
381
+ voided()
382
+ if seen not in endpoints:
383
+ raise BlenderConnectionError(
384
+ f"MESHWIRE_ADDON_ADDRESS is set to {override}, but the add-on that answered "
385
+ f"is listening on {seen}. That variable is what this server compared "
386
+ f"against, not the address it connected to ({peer}). Set it to {seen} if "
387
+ "that is the tunnel or port forward you meant."
388
+ )
389
+ elif seen != peer:
390
+ raise BlenderConnectionError(
391
+ f"The Meshwire add-on that answered is listening on {seen}, but this server "
392
+ f"connected to {peer}. Another program may be relaying the connection. "
393
+ "If you use an SSH tunnel or port forward on purpose, set "
394
+ f"MESHWIRE_ADDON_ADDRESS={seen} for the MCP client."
395
+ )
396
+
397
+ proof_c = wire.proof(token, wire.CLIENT_LABEL, nonce_s, nonce_c)
398
+ await self._send(writer, wire.hello_proof_request(proof_c, str(uuid.uuid4())), timeout)
399
+ voided()
400
+ ok = (await self._read_reply(reader, timeout, peer, verified=True)).get("result")
401
+ voided()
402
+ if not (isinstance(ok, dict) and ok.get("ok") is True):
403
+ raise BlenderConnectionError("The Meshwire add-on did not accept the handshake")
404
+
405
+ @staticmethod
406
+ async def _override_endpoints(value: str, timeout: float) -> set[str]:
407
+ """Every ``host:port`` MESHWIRE_ADDON_ADDRESS can mean, normalised like ``seen``.
408
+
409
+ A literal address is normalised (brackets, IPv4-mapped prefix). A hostname such as
410
+ ``localhost:9876`` is resolved, within ``timeout``, and any of its addresses
411
+ matches. A port outside 0 to 65535 matches nothing.
412
+ """
413
+ if value.startswith("["):
414
+ host, _, rest = value[1:].partition("]")
415
+ port_text = rest.lstrip(":")
416
+ elif value.count(":") == 1:
417
+ host, _, port_text = value.partition(":")
418
+ else: # a bare IPv6 literal with the port after the last colon
419
+ host, _, port_text = value.rpartition(":")
420
+ if not port_text.isdigit() or int(port_text) > 65535:
421
+ return set() # getaddrinfo would wrap 70000 to 4464
422
+ port = int(port_text)
423
+ found = {wire.endpoint(host, port)}
424
+ try:
425
+ infos = await asyncio.wait_for(
426
+ asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM),
427
+ timeout=timeout,
428
+ )
429
+ except (OSError, UnicodeError, asyncio.TimeoutError):
430
+ # Unresolvable, unencodable (``a..b``) or too slow: only the literal form matches.
431
+ return found
432
+ found.update(wire.endpoint(str(info[4][0]), int(info[4][1])) for info in infos)
433
+ return found
434
+
435
+ @staticmethod
436
+ def _protocol_error(addon_protocol: Any) -> BlenderConnectionError:
437
+ """From the add-on's protocol number alone: which side is older?"""
438
+ if type(addon_protocol) is int and addon_protocol > wire.PROTOCOL:
439
+ side = "Update the Meshwire server."
440
+ elif type(addon_protocol) is int and addon_protocol < wire.PROTOCOL:
441
+ side = "Update the Meshwire add-on in Blender."
442
+ else:
443
+ side = "Update the Meshwire server and the add-on to the same release."
444
+ if type(addon_protocol) is int:
445
+ return _Unproven(
446
+ f"The Meshwire add-on in Blender speaks protocol {addon_protocol}, this server "
447
+ f"speaks {wire.PROTOCOL}. {side}"
448
+ )
449
+ return _Unproven(
450
+ f"The Meshwire add-on in Blender and this server speak different protocols. {side}"
451
+ )
452
+
453
+ def _hello_error(self, error: Any, peer: str, verified: bool) -> BlenderConnectionError:
454
+ code = error.get("code") if isinstance(error, dict) else None
455
+ message = error.get("message") if isinstance(error, dict) else None
456
+ message = message if isinstance(message, str) else ""
457
+ if verified:
458
+ # The add-on has proved it holds the token, so its own words can be shown.
459
+ if code == wire.E_UNAUTHORIZED:
460
+ return BlenderConnectionError(
461
+ "The Meshwire add-on in Blender rejected this server's proof. "
462
+ f"{self._token_help()}"
463
+ )
464
+ return BlenderConnectionError(message or f"Blender refused the handshake ({code})")
465
+ # Before that, a peer's text could be anything. Use codes and numbers only.
466
+ # A v0.4.0 add-on has no hello: its dispatcher raises ValueError("Unknown
467
+ # method: hello") and the socket server wraps that as -32603.
468
+ if code == -32603 and message.startswith("Unknown method"):
469
+ return _Unproven(
470
+ "The Meshwire add-on in Blender is older than this server. Update the add-on."
471
+ )
472
+ if code == wire.E_PROTOCOL:
473
+ data = error.get("data") if isinstance(error, dict) else None
474
+ number = data.get("protocol") if isinstance(data, dict) else None
475
+ return self._protocol_error(number)
476
+ return _NotMeshwire(f"The program on {peer} did not complete the Meshwire handshake.")
477
+
478
+ async def disconnect(self) -> None:
479
+ """Disconnect from Blender."""
480
+ self._connected = False
481
+ self._epoch += 1 # voids an _open() still waiting on its hello
482
+
483
+ # Take everything this connection owns before the first await below, so a
484
+ # send_command that reconnects meanwhile installs state we never touch.
485
+ read_task, self._read_task = self._read_task, None
486
+ writer, self._writer = self._writer, None
487
+ self._reader = None
488
+ pending = list(self._pending_requests.values())
489
+ self._pending_requests.clear()
490
+
491
+ # The read loop disconnects itself when the socket closes; it cannot await itself.
492
+ if read_task and read_task is not asyncio.current_task():
493
+ read_task.cancel()
494
+ try:
495
+ await read_task
496
+ except asyncio.CancelledError:
497
+ pass
498
+
499
+ if writer:
500
+ writer.close()
501
+ try:
502
+ await writer.wait_closed()
503
+ except Exception:
504
+ pass
505
+
506
+ # Cancel any pending requests
507
+ for future in pending:
508
+ if not future.done():
509
+ future.set_exception(BlenderConnectionError("Disconnected"))
510
+
511
+ logger.info("Disconnected from Blender")
512
+
513
+ async def send_command(
514
+ self,
515
+ method: str,
516
+ params: dict[str, Any] | None = None,
517
+ timeout: float = 30.0,
518
+ ) -> Any:
519
+ """
520
+ Send a command to Blender and wait for the response.
521
+
522
+ Args:
523
+ method: The command method name
524
+ params: Command parameters
525
+ timeout: Response timeout in seconds
526
+
527
+ Returns:
528
+ The result from Blender
529
+
530
+ Raises:
531
+ BlenderConnectionError: If not connected
532
+ BlenderCommandError: If the command fails
533
+ asyncio.TimeoutError: If response times out
534
+ """
535
+ if not self._connected:
536
+ await self.connect()
537
+
538
+ if not self._writer:
539
+ raise BlenderConnectionError("Not connected to Blender")
540
+
541
+ # Generate unique request ID
542
+ request_id = str(uuid.uuid4())
543
+
544
+ # Build JSON-RPC request
545
+ request = {
546
+ "jsonrpc": "2.0",
547
+ "method": method,
548
+ "params": params or {},
549
+ "id": request_id,
550
+ }
551
+
552
+ # Create future for response
553
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
554
+ self._pending_requests[request_id] = future
555
+
556
+ try:
557
+ # Send request
558
+ async with self._lock:
559
+ message = json.dumps(request) + "\n"
560
+ self._writer.write(message.encode("utf-8"))
561
+ await self._writer.drain()
562
+
563
+ logger.debug(f"Sent command: {method}")
564
+
565
+ # Wait for response
566
+ result = await asyncio.wait_for(future, timeout=timeout)
567
+ return result
568
+
569
+ except asyncio.TimeoutError:
570
+ self._pending_requests.pop(request_id, None)
571
+ raise asyncio.TimeoutError(f"Command '{method}' timed out after {timeout}s")
572
+
573
+ except Exception:
574
+ self._pending_requests.pop(request_id, None)
575
+ raise
576
+
577
+ async def _read_loop(self) -> None:
578
+ """Background task to read responses from Blender."""
579
+ buffer = bytearray()
580
+ scanned = 0 # bytes of buffer already known to hold no newline
581
+
582
+ while self._connected and self._reader:
583
+ try:
584
+ data = await self._reader.read(READ_SIZE)
585
+ if not data:
586
+ # Connection closed
587
+ logger.warning("Connection closed by Blender")
588
+ await self.disconnect()
589
+ break
590
+
591
+ buffer += data
592
+
593
+ # Process complete messages
594
+ while (end := buffer.find(b"\n", scanned)) >= 0:
595
+ line = bytes(buffer[:end])
596
+ del buffer[: end + 1]
597
+ scanned = 0
598
+ if line.strip():
599
+ await self._handle_response(line)
600
+ scanned = len(buffer)
601
+
602
+ if len(buffer) > MAX_REPLY_LINE:
603
+ self._fail_pending(
604
+ BlenderConnectionError(
605
+ f"Reply from Blender too large (over {MAX_REPLY_LINE} bytes)"
606
+ )
607
+ )
608
+ await self.disconnect()
609
+ break
610
+
611
+ except asyncio.CancelledError:
612
+ break
613
+ except Exception as e:
614
+ logger.error(f"Read error: {e}")
615
+ await self.disconnect()
616
+ break
617
+
618
+ def _fail_pending(self, exc: Exception) -> None:
619
+ """Fail every request still waiting for a reply with ``exc``."""
620
+ pending = list(self._pending_requests.values())
621
+ self._pending_requests.clear()
622
+ for future in pending:
623
+ if not future.done():
624
+ future.set_exception(exc)
625
+
626
+ async def _handle_response(self, data: bytes) -> None:
627
+ """Handle a JSON-RPC response from Blender."""
628
+ try:
629
+ response = json.loads(data.decode("utf-8"))
630
+ except json.JSONDecodeError as e:
631
+ logger.error(f"Invalid JSON response: {e}")
632
+ return
633
+
634
+ if not isinstance(response, dict):
635
+ logger.error("Reply from Blender is not a JSON object")
636
+ return
637
+
638
+ request_id = response.get("id")
639
+ if request_id is None:
640
+ # The add-on could not tell which request was bad (parse error, too many
641
+ # clients, oversize line) and is about to close the connection. Every
642
+ # waiting request gets its reason.
643
+ error = response.get("error")
644
+ if error:
645
+ message = (
646
+ error.get("message", str(error)) if isinstance(error, dict) else str(error)
647
+ )
648
+ self._fail_pending(BlenderCommandError(message))
649
+ else:
650
+ logger.warning("Received response without ID")
651
+ return
652
+
653
+ future = self._pending_requests.pop(request_id, None)
654
+ if future is None:
655
+ logger.warning(f"Received response for unknown request: {request_id}")
656
+ return
657
+
658
+ if "error" in response:
659
+ error = response["error"]
660
+ error_msg = error.get("message", str(error))
661
+ future.set_exception(BlenderCommandError(error_msg))
662
+ else:
663
+ future.set_result(response.get("result"))
664
+
665
+ async def ping(self, timeout: float = 5.0) -> bool:
666
+ """
667
+ Test connectivity to Blender.
668
+
669
+ Returns:
670
+ True if connected and responsive
671
+ """
672
+ try:
673
+ result = await self.send_command("ping", timeout=timeout)
674
+ return result.get("pong", False)
675
+ except Exception:
676
+ return False
677
+
678
+ async def __aenter__(self) -> "BlenderClient":
679
+ """Async context manager entry."""
680
+ await self.connect()
681
+ return self
682
+
683
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
684
+ """Async context manager exit."""
685
+ await self.disconnect()
mcp_blender/py.typed ADDED
File without changes