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.
- mcp_blender/__init__.py +3 -0
- mcp_blender/blender_client.py +685 -0
- mcp_blender/py.typed +0 -0
- mcp_blender/server.py +6528 -0
- mcp_blender/tools/__init__.py +21 -0
- mcp_blender/tools/animation.py +75 -0
- mcp_blender/tools/export.py +80 -0
- mcp_blender/tools/external.py +553 -0
- mcp_blender/tools/materials.py +96 -0
- mcp_blender/tools/modifiers.py +66 -0
- mcp_blender/tools/objects.py +136 -0
- mcp_blender/tools/render.py +74 -0
- mcp_blender/tools/scene.py +56 -0
- mcp_blender/types.py +106 -0
- mcp_blender/wire.py +478 -0
- meshwire-0.5.0.dist-info/METADATA +476 -0
- meshwire-0.5.0.dist-info/RECORD +20 -0
- meshwire-0.5.0.dist-info/WHEEL +4 -0
- meshwire-0.5.0.dist-info/entry_points.txt +3 -0
- meshwire-0.5.0.dist-info/licenses/LICENSE +661 -0
mcp_blender/__init__.py
ADDED
|
@@ -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
|