@dpantani/tdmcp 0.1.0 → 0.3.0

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.
@@ -16,6 +16,18 @@ from mcp import events
16
16
  from mcp.services import analysis_service, api_service, batch_service, preview_service
17
17
 
18
18
 
19
+ class _Unauthorized(PermissionError):
20
+ """Authentication failed — missing or invalid bearer token (HTTP 401)."""
21
+
22
+ status = 401
23
+
24
+
25
+ class _Forbidden(PermissionError):
26
+ """Request refused regardless of credentials — cross-origin or exec disabled (HTTP 403)."""
27
+
28
+ status = 403
29
+
30
+
19
31
  def _required_token():
20
32
  """The shared bearer token the bridge enforces, or None when auth is off.
21
33
 
@@ -53,8 +65,14 @@ def _find_header(request, name):
53
65
  if not isinstance(node, dict) or depth > 2:
54
66
  return None
55
67
  for key, value in node.items():
56
- if isinstance(key, str) and key.lower() == target and isinstance(value, str):
57
- return value
68
+ if isinstance(key, str) and key.lower() == target:
69
+ # TD builds vary: a header may arrive as a str or as a list of
70
+ # strings (repeated header). Accept a list's first string element
71
+ # so the Origin guard never fails *open* on a list-shaped header.
72
+ if isinstance(value, str):
73
+ return value
74
+ if isinstance(value, (list, tuple)) and value and isinstance(value[0], str):
75
+ return value[0]
58
76
  for nested in ("headers", "header", "fields"):
59
77
  sub = node.get(nested)
60
78
  hit = scan(sub, depth + 1) if isinstance(sub, dict) else None
@@ -71,7 +89,32 @@ def _check_auth(request):
71
89
  return # auth disabled (default)
72
90
  provided = (_find_header(request, "authorization") or "").strip()
73
91
  if not hmac.compare_digest(provided, "Bearer " + token):
74
- raise PermissionError("Unauthorized: missing or invalid bearer token.")
92
+ raise _Unauthorized("Unauthorized: missing or invalid bearer token.")
93
+
94
+
95
+ _LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
96
+
97
+
98
+ def _check_origin(request):
99
+ """Reject browser-originated cross-origin requests (CSRF / DNS-rebinding).
100
+
101
+ The Node MCP server — the only legitimate caller — never sends an `Origin`
102
+ header. Browsers always attach one on cross-site requests, so a request
103
+ bearing a non-loopback `Origin` can only be a web page trying to drive the
104
+ bridge (e.g. a malicious site POSTing to http://127.0.0.1:9980/api/exec).
105
+ Under the default zero-auth + exec-on config that would be drive-by remote
106
+ code execution, so refuse it. Loopback origins stay allowed (a locally
107
+ served tool page still works) and same-origin / no-Origin callers are
108
+ unaffected. Holds even against a direct caller and independent of the
109
+ optional bearer token, mirroring the Node HTTP transport's DNS-rebinding
110
+ guard on its own port.
111
+ """
112
+ origin = _find_header(request, "origin")
113
+ if not origin:
114
+ return
115
+ host = urlparse(origin).hostname
116
+ if host not in _LOOPBACK_HOSTS:
117
+ raise _Forbidden("Forbidden: cross-origin request rejected (origin %r)." % origin)
75
118
 
76
119
 
77
120
  def _qs(query, key, default=None):
@@ -101,6 +144,20 @@ def _node_path(segments):
101
144
  return "/" + raw.lstrip("/")
102
145
 
103
146
 
147
+ def _require(body, *keys):
148
+ """Raise a descriptive error when a required body field is absent.
149
+
150
+ Without this, `body["script"]`/`body["type"]` etc. raise a bare KeyError whose
151
+ message is only the missing key name — opaque to a direct caller. The Node
152
+ client validates inputs with zod first, so this only fires for raw callers.
153
+ """
154
+ if not isinstance(body, dict):
155
+ raise ValueError("Request body must be a JSON object.")
156
+ missing = [k for k in keys if k not in body]
157
+ if missing:
158
+ raise ValueError("Missing required field(s): %s." % ", ".join(missing))
159
+
160
+
104
161
  def _route(method, path, query, body):
105
162
  parts = [p for p in path.split("/") if p]
106
163
  if not parts or parts[0] != "api":
@@ -111,6 +168,7 @@ def _route(method, path, query, body):
111
168
  return api_service.get_info()
112
169
 
113
170
  if rest == ["nodes"] and method == "POST":
171
+ _require(body, "parent_path", "type")
114
172
  return api_service.create_node(
115
173
  body["parent_path"], body["type"], body.get("name"), body.get("parameters")
116
174
  )
@@ -119,7 +177,8 @@ def _route(method, path, query, body):
119
177
 
120
178
  if rest == ["exec"] and method == "POST":
121
179
  if not _exec_allowed():
122
- raise PermissionError("Forbidden: arbitrary code execution is disabled (TDMCP_BRIDGE_ALLOW_EXEC=0).")
180
+ raise _Forbidden("Forbidden: arbitrary code execution is disabled (TDMCP_BRIDGE_ALLOW_EXEC=0).")
181
+ _require(body, "script")
123
182
  return api_service.exec_script(body["script"], body.get("return_output", True))
124
183
 
125
184
  if rest == ["batch"] and method == "POST":
@@ -128,9 +187,10 @@ def _route(method, path, query, body):
128
187
  if rest[0] == "nodes" and len(rest) >= 2:
129
188
  if rest[-1] == "method" and method == "POST":
130
189
  if not _exec_allowed():
131
- raise PermissionError(
190
+ raise _Forbidden(
132
191
  "Forbidden: arbitrary method calls are disabled (TDMCP_BRIDGE_ALLOW_EXEC=0)."
133
192
  )
193
+ _require(body, "method")
134
194
  return api_service.call_method(
135
195
  _node_path(rest[1:-1]),
136
196
  body["method"],
@@ -160,7 +220,9 @@ def _route(method, path, query, body):
160
220
  if kind == "topology":
161
221
  return analysis_service.topology(node_path, recursive=_qs(query, "recursive") == "true")
162
222
  if kind == "performance":
163
- return analysis_service.performance(node_path)
223
+ return analysis_service.performance(
224
+ node_path, recursive=_qs(query, "recursive") == "true"
225
+ )
164
226
 
165
227
  raise ValueError("Unsupported %s %s" % (method, path))
166
228
 
@@ -226,6 +288,7 @@ def _emit_event(webserver, method, path, data):
226
288
 
227
289
  def handle(request, response, webserver=None):
228
290
  try:
291
+ _check_origin(request)
229
292
  _check_auth(request)
230
293
  method = (request.get("method") or "GET").upper()
231
294
  parsed = urlparse(request.get("uri", "/"))
@@ -235,6 +298,6 @@ def handle(request, response, webserver=None):
235
298
  _emit_event(webserver, method, parsed.path, data)
236
299
  return _send(response, 200, {"ok": True, "data": data})
237
300
  except PermissionError as exc:
238
- return _send(response, 401, {"ok": False, "error": {"message": str(exc)}})
301
+ return _send(response, getattr(exc, "status", 403), {"ok": False, "error": {"message": str(exc)}})
239
302
  except Exception as exc: # noqa: BLE001
240
303
  return _send(response, 400, {"ok": False, "error": {"message": str(exc)}})
@@ -44,13 +44,18 @@ def topology(path, recursive=False):
44
44
  return {"nodes": nodes, "connections": connections}
45
45
 
46
46
 
47
- def performance(path):
47
+ def performance(path, recursive=False):
48
48
  root = op(path) # noqa: F821
49
49
  if root is None:
50
50
  return {"nodes": [], "total_cook_time_ms": 0.0}
51
51
  nodes = []
52
52
  total = 0.0
53
- children = root.findChildren(depth=1) if hasattr(root, "findChildren") else []
53
+ if not hasattr(root, "findChildren"):
54
+ children = []
55
+ elif recursive:
56
+ children = root.findChildren() # all descendants (cook time of nested nodes too)
57
+ else:
58
+ children = root.findChildren(depth=1) # direct children only
54
59
  for child in children:
55
60
  cook_time = float(getattr(child, "cookTime", 0.0) or 0.0)
56
61
  total += cook_time
@@ -71,9 +71,15 @@ def create_node(parent_path, type_name, name=None, parameters=None):
71
71
  raise LookupError("Parent not found: %s" % parent_path)
72
72
  cls = _resolve_type(type_name)
73
73
  node = parent.create(cls, name) if name else parent.create(cls)
74
+ ref = node_ref(node)
74
75
  if parameters:
75
- apply_parameters(node, parameters)
76
- return node_ref(node)
76
+ # The node is created regardless; surface any params that did not apply
77
+ # (unknown name or bad value) as a non-fatal warning rather than dropping
78
+ # them silently. The caller (create_td_node) relays these to the user.
79
+ _applied, failed = apply_parameters(node, parameters)
80
+ if failed:
81
+ ref["parameter_warnings"] = sorted(failed)
82
+ return ref
77
83
 
78
84
 
79
85
  def delete_node(path):
@@ -120,7 +126,23 @@ def update_parameters(path, parameters):
120
126
  node = op(path) # noqa: F821
121
127
  if node is None:
122
128
  raise LookupError("Node not found: %s" % path)
123
- apply_parameters(node, parameters)
129
+ params = parameters or {}
130
+ # Reject unknown parameter names up front (atomic: apply nothing) so a typo
131
+ # like `gain` on a levelTOP fails loudly instead of being silently dropped.
132
+ unknown = [k for k in params if getattr(node.par, k, None) is None]
133
+ if unknown:
134
+ raise ValueError(
135
+ "Unknown parameter(s) on %s (%s): %s. "
136
+ "Use get_td_node_parameters to see the valid parameter names."
137
+ % (path, op_type(node), ", ".join(sorted(unknown)))
138
+ )
139
+ applied, failed = apply_parameters(node, params)
140
+ if failed:
141
+ raise ValueError(
142
+ "Could not set parameter(s) on %s (%s): %s "
143
+ "(wrong value type or out of range?). Applied: %s."
144
+ % (path, op_type(node), ", ".join(sorted(failed)), ", ".join(sorted(applied)) or "none")
145
+ )
124
146
  return node_detail(node)
125
147
 
126
148
 
@@ -11,8 +11,42 @@ def connect(source_path, target_path, source_output=0, target_input=0):
11
11
  src = op(source_path)
12
12
  dst = op(target_path)
13
13
  if src is None or dst is None:
14
- raise LookupError("Source or target not found")
15
- dst.inputConnectors[target_input].connect(src.outputConnectors[source_output])
14
+ raise LookupError("Source or target not found: %s -> %s" % (source_path, target_path))
15
+ src_parent = src.parent()
16
+ dst_parent = dst.parent()
17
+ # TD wires only connect operators sharing a parent. A cross-container connect
18
+ # silently no-ops (no exception, no wire), so reject it with an actionable msg.
19
+ if src_parent is None or dst_parent is None or src_parent.path != dst_parent.path:
20
+ raise ValueError(
21
+ "Cannot wire across containers: %s (in %s) -> %s (in %s). "
22
+ "Wires only connect operators sharing a parent; to bring an operator "
23
+ "across networks use a Select/In OP (e.g. a Select TOP/CHOP whose source "
24
+ "parameter points at %r)."
25
+ % (
26
+ src.path,
27
+ getattr(src_parent, "path", "<root>"),
28
+ dst.path,
29
+ getattr(dst_parent, "path", "<root>"),
30
+ source_path,
31
+ )
32
+ )
33
+ try:
34
+ in_conn = dst.inputConnectors[target_input]
35
+ out_conn = src.outputConnectors[source_output]
36
+ except IndexError:
37
+ raise IndexError(
38
+ "Connector index out of range: target_input=%d (%s has %d input(s)), "
39
+ "source_output=%d (%s has %d output(s))."
40
+ % (
41
+ target_input,
42
+ dst.path,
43
+ len(dst.inputConnectors),
44
+ source_output,
45
+ src.path,
46
+ len(src.outputConnectors),
47
+ )
48
+ )
49
+ in_conn.connect(out_conn)
16
50
 
17
51
 
18
52
  def run(operations):
@@ -1,4 +1,10 @@
1
- """Capture a TOP as a base64-encoded PNG."""
1
+ """Capture a TOP as a base64-encoded PNG.
2
+
3
+ The TOP is composited over a checkerboard first, so transparent regions read as a
4
+ checker pattern instead of solid white when a viewer flattens the PNG's alpha over a
5
+ white page. Opaque TOPs are unaffected (the checker stays fully hidden). If the
6
+ composite path errors for any reason, we fall back to saving the TOP directly.
7
+ """
2
8
 
3
9
  import base64
4
10
 
@@ -6,14 +12,18 @@ import td
6
12
 
7
13
  op = td.op # TD globals are not available inside imported modules; reach via td
8
14
 
15
+ # Mid-gray checkerboard (16x9 cells). Self-contained fragment shader, no inputs/uniforms.
16
+ _CHECKER_FRAG = """out vec4 fragColor;
17
+ void main(){
18
+ vec2 cell = floor(vUV.st * vec2(16.0, 9.0));
19
+ float k = mod(cell.x + cell.y, 2.0);
20
+ fragColor = vec4(mix(vec3(0.16), vec3(0.30), k), 1.0);
21
+ }
22
+ """
9
23
 
10
- def capture(path, width=640, height=360):
11
- node = op(path)
12
- if node is None:
13
- raise LookupError("Node not found: %s" % path)
14
- if getattr(node, "family", None) != "TOP":
15
- raise ValueError("Preview is only supported for TOPs, got %s" % path)
16
24
 
25
+ def _save_png(node):
26
+ """Return the node's PNG bytes, via saveByteArray with a temp-file fallback."""
17
27
  data = None
18
28
  try:
19
29
  data = node.saveByteArray(".png")
@@ -21,7 +31,6 @@ def capture(path, width=640, height=360):
21
31
  data = None
22
32
 
23
33
  if data is None:
24
- # Fallback: save to a temp file and read it back.
25
34
  import os
26
35
  import tempfile
27
36
 
@@ -30,11 +39,80 @@ def capture(path, width=640, height=360):
30
39
  with open(tmp, "rb") as handle:
31
40
  data = handle.read()
32
41
 
33
- encoded = base64.b64encode(bytes(data)).decode("ascii")
42
+ return bytes(data)
43
+
44
+
45
+ def _checkerboard_png(node, width, height):
46
+ """Composite `node` over a checkerboard and return the flattened PNG bytes.
47
+
48
+ Creates a few temporary nodes in the node's parent and destroys them afterwards
49
+ (even on error). Returns None if the composite could not be produced, so callers
50
+ can fall back to a direct save.
51
+ """
52
+ parent = node.parent()
53
+ if parent is None:
54
+ return None
55
+
56
+ temps = []
57
+ try:
58
+ frag = parent.create("textDAT", "__tdmcp_pv_frag")
59
+ temps.append(frag)
60
+ frag.text = _CHECKER_FRAG
61
+
62
+ bg = parent.create("glslTOP", "__tdmcp_pv_bg")
63
+ temps.append(bg)
64
+ bg.par.pixeldat = frag.name
65
+ bg.par.outputresolution = "custom"
66
+ bg.par.resolutionw = width
67
+ bg.par.resolutionh = height
68
+
69
+ comp = parent.create("compositeTOP", "__tdmcp_pv_comp")
70
+ temps.append(comp)
71
+ comp.par.operand = "over"
72
+ comp.par.outputresolution = "custom"
73
+ comp.par.resolutionw = width
74
+ comp.par.resolutionh = height
75
+ comp.inputConnectors[0].connect(node) # foreground (over)
76
+ comp.inputConnectors[1].connect(bg) # background (under)
77
+ comp.cook(force=True)
78
+
79
+ if comp.errors():
80
+ return None
81
+ return _save_png(comp)
82
+ except Exception: # noqa: BLE001
83
+ return None
84
+ finally:
85
+ for t in reversed(temps):
86
+ try:
87
+ t.destroy()
88
+ except Exception: # noqa: BLE001
89
+ pass
90
+
91
+
92
+ def capture(path, width=640, height=360):
93
+ node = op(path)
94
+ if node is None:
95
+ raise LookupError("Node not found: %s" % path)
96
+ if getattr(node, "family", None) != "TOP":
97
+ raise ValueError("Preview is only supported for TOPs, got %s" % path)
98
+
99
+ w = int(getattr(node, "width", width) or width)
100
+ h = int(getattr(node, "height", height) or height)
101
+ # Clamp to a sane preview ceiling so a hostile/huge request (or a TOP with an
102
+ # extreme resolution) can't allocate a multi-gigapixel GPU texture and exhaust
103
+ # VRAM / hang TD. A preview is a thumbnail; 4096 on a side is plenty.
104
+ w = max(1, min(w, 4096))
105
+ h = max(1, min(h, 4096))
106
+
107
+ data = _checkerboard_png(node, w, h)
108
+ if data is None:
109
+ data = _save_png(node)
110
+
111
+ encoded = base64.b64encode(data).decode("ascii")
34
112
  return {
35
113
  "path": node.path,
36
- "width": int(getattr(node, "width", width) or width),
37
- "height": int(getattr(node, "height", height) or height),
114
+ "width": w,
115
+ "height": h,
38
116
  "format": "png",
39
117
  "base64": encoded,
40
118
  }
@@ -1,3 +1,3 @@
1
1
  """Version of the tdmcp TouchDesigner bridge."""
2
2
 
3
- BRIDGE_VERSION = "0.2.0"
3
+ BRIDGE_VERSION = "0.3.0"
@@ -179,6 +179,17 @@ class RoutingTests(unittest.TestCase):
179
179
  with self.assertRaises(ValueError):
180
180
  ac._route("GET", "/api/does-not-exist", {}, {})
181
181
 
182
+ def test_create_node_missing_fields_raises_descriptive(self):
183
+ with self.assertRaises(ValueError) as cm:
184
+ ac._route("POST", "/api/nodes", {}, {})
185
+ self.assertIn("parent_path", str(cm.exception))
186
+ self.assertIn("type", str(cm.exception))
187
+
188
+ def test_exec_missing_script_raises_descriptive(self):
189
+ with self.assertRaises(ValueError) as cm:
190
+ ac._route("POST", "/api/exec", {}, {})
191
+ self.assertIn("script", str(cm.exception))
192
+
182
193
 
183
194
  class ParsingTests(unittest.TestCase):
184
195
  def test_parse_body_variants(self):
@@ -201,6 +212,52 @@ class ParsingTests(unittest.TestCase):
201
212
  self.assertEqual(ac._find_header({"X-Token": "abc"}, "x-token"), "abc")
202
213
  self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
203
214
 
215
+ def test_find_header_accepts_list_value(self):
216
+ # A repeated/multi-value header may arrive as a list; take the first str.
217
+ self.assertEqual(ac._find_header({"Origin": ["http://x", "http://y"]}, "origin"), "http://x")
218
+ self.assertIsNone(ac._find_header({"Origin": []}, "origin"))
219
+ self.assertIsNone(ac._find_header({"Origin": [123]}, "origin"))
220
+
221
+
222
+ class OriginTests(unittest.TestCase):
223
+ """The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
224
+
225
+ def test_no_origin_is_allowed(self):
226
+ ac._check_origin({"method": "GET"}) # the Node client sends no Origin
227
+
228
+ def test_loopback_origins_allowed(self):
229
+ for origin in (
230
+ "http://127.0.0.1:9980",
231
+ "http://localhost",
232
+ "https://localhost:3000",
233
+ "http://[::1]:9980",
234
+ ):
235
+ ac._check_origin({"Origin": origin}) # must not raise
236
+
237
+ def test_cross_origin_rejected(self):
238
+ for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
239
+ with self.assertRaises(PermissionError, msg=origin):
240
+ ac._check_origin({"Origin": origin})
241
+
242
+ def test_opaque_null_origin_rejected(self):
243
+ with self.assertRaises(PermissionError):
244
+ ac._check_origin({"Origin": "null"})
245
+
246
+ def test_origin_lookup_is_case_insensitive_and_nested(self):
247
+ with self.assertRaises(PermissionError):
248
+ ac._check_origin({"headers": {"origin": "http://evil.com"}})
249
+
250
+ def test_handle_rejects_cross_origin_with_403(self):
251
+ resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
252
+ self.assertEqual(resp["statusCode"], 403)
253
+ self.assertIn("cross-origin", resp["data"])
254
+
255
+ def test_handle_rejects_list_valued_cross_origin(self):
256
+ # Some TD builds surface a header as a list; it must not slip the guard.
257
+ resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": ["http://evil.com"]}, {})
258
+ self.assertEqual(resp["statusCode"], 403)
259
+ self.assertIn("cross-origin", resp["data"])
260
+
204
261
 
205
262
  class HandleTests(unittest.TestCase):
206
263
  def tearDown(self):
@@ -229,14 +286,22 @@ class HandleTests(unittest.TestCase):
229
286
  self.assertEqual(resp["statusCode"], 400)
230
287
  self.assertIn("boom", resp["data"])
231
288
 
232
- def test_exec_disabled_surfaces_as_401_with_message(self):
289
+ def test_exec_disabled_surfaces_as_403_with_message(self):
233
290
  os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
234
291
  resp = ac.handle(
235
292
  {"method": "POST", "uri": "/api/exec", "data": '{"script": "1"}'}, self._resp()
236
293
  )
237
- self.assertEqual(resp["statusCode"], 401)
294
+ self.assertEqual(resp["statusCode"], 403)
238
295
  self.assertIn("disabled", resp["data"])
239
296
 
297
+ def test_missing_required_field_surfaces_as_400_with_name(self):
298
+ # A POST with valid JSON but a missing field gets a descriptive 400,
299
+ # not a bare KeyError message of just the key name.
300
+ resp = ac.handle({"method": "POST", "uri": "/api/nodes", "data": "{}"}, self._resp())
301
+ self.assertEqual(resp["statusCode"], 400)
302
+ self.assertIn("parent_path", resp["data"])
303
+ self.assertIn("Missing required field", resp["data"])
304
+
240
305
 
241
306
  if __name__ == "__main__":
242
307
  unittest.main()