@dpantani/tdmcp 0.2.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.
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@dpantani/tdmcp",
3
- "version": "0.2.0",
4
- "description": "AI-native visual creation for TouchDesigner — a production-grade MCP server.",
3
+ "mcpName": "io.github.Pantani/tdmcp",
4
+ "version": "0.3.0",
5
+ "description": "tdmcp — the TouchDesigner MCP server. Build real TouchDesigner visual systems from plain language with Claude, Cursor, or Codex (Model Context Protocol).",
5
6
  "type": "module",
6
7
  "license": "MIT",
8
+ "homepage": "https://pantani.github.io/tdmcp/",
7
9
  "engines": {
8
10
  "node": ">=20"
9
11
  },
@@ -41,6 +43,10 @@
41
43
  "validate:recipes": "tsx scripts/validate-recipes.ts",
42
44
  "smoke:live": "tsx scripts/smoke-live.ts",
43
45
  "build:dxt": "node scripts/build-dxt.mjs",
46
+ "docs:gen": "tsx scripts/gen-tool-docs.ts",
47
+ "docs:dev": "npm run docs:gen && vitepress dev docs",
48
+ "docs:build": "npm run docs:gen && vitepress build docs",
49
+ "docs:preview": "vitepress preview docs",
44
50
  "version": "node scripts/sync-manifest-version.mjs && git add dxt/manifest.json",
45
51
  "prepack": "node scripts/clean-pycache.mjs",
46
52
  "prepublishOnly": "npm run build && npm test"
@@ -50,14 +56,25 @@
50
56
  },
51
57
  "keywords": [
52
58
  "touchdesigner",
59
+ "touchdesigner-mcp",
53
60
  "mcp",
61
+ "mcp-server",
54
62
  "model-context-protocol",
63
+ "claude",
64
+ "cursor",
65
+ "codex",
66
+ "ai",
55
67
  "visual",
56
68
  "creative-coding",
57
- "generative-art"
69
+ "generative-art",
70
+ "vj",
71
+ "glsl",
72
+ "audio-reactive",
73
+ "derivative"
58
74
  ],
59
75
  "dependencies": {
60
76
  "@modelcontextprotocol/sdk": "^1.29.0",
77
+ "gray-matter": "^4.0.3",
61
78
  "zod": "^4.4.3"
62
79
  },
63
80
  "devDependencies": {
@@ -69,6 +86,7 @@
69
86
  "tsup": "^8.5.1",
70
87
  "tsx": "^4.22.3",
71
88
  "typescript": "^6.0.3",
89
+ "vitepress": "^1.6.4",
72
90
  "vitest": "^4.1.7"
73
91
  }
74
92
  }
package/td/README.md CHANGED
@@ -102,7 +102,7 @@ Verify from a terminal:
102
102
 
103
103
  ```bash
104
104
  curl http://127.0.0.1:9980/api/info
105
- # {"ok":true,"data":{"python_version":"3.11.x","td_version":"...","bridge_version":"0.1.0"}}
105
+ # {"ok":true,"data":{"python_version":"3.11.x","td_version":"...","bridge_version":"0.3.0"}}
106
106
  ```
107
107
 
108
108
  Then run the live smoke test from the repo root:
@@ -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,7 @@ 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.")
75
93
 
76
94
 
77
95
  _LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
@@ -96,7 +114,7 @@ def _check_origin(request):
96
114
  return
97
115
  host = urlparse(origin).hostname
98
116
  if host not in _LOOPBACK_HOSTS:
99
- raise PermissionError("Forbidden: cross-origin request rejected (origin %r)." % origin)
117
+ raise _Forbidden("Forbidden: cross-origin request rejected (origin %r)." % origin)
100
118
 
101
119
 
102
120
  def _qs(query, key, default=None):
@@ -126,6 +144,20 @@ def _node_path(segments):
126
144
  return "/" + raw.lstrip("/")
127
145
 
128
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
+
129
161
  def _route(method, path, query, body):
130
162
  parts = [p for p in path.split("/") if p]
131
163
  if not parts or parts[0] != "api":
@@ -136,6 +168,7 @@ def _route(method, path, query, body):
136
168
  return api_service.get_info()
137
169
 
138
170
  if rest == ["nodes"] and method == "POST":
171
+ _require(body, "parent_path", "type")
139
172
  return api_service.create_node(
140
173
  body["parent_path"], body["type"], body.get("name"), body.get("parameters")
141
174
  )
@@ -144,7 +177,8 @@ def _route(method, path, query, body):
144
177
 
145
178
  if rest == ["exec"] and method == "POST":
146
179
  if not _exec_allowed():
147
- 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")
148
182
  return api_service.exec_script(body["script"], body.get("return_output", True))
149
183
 
150
184
  if rest == ["batch"] and method == "POST":
@@ -153,9 +187,10 @@ def _route(method, path, query, body):
153
187
  if rest[0] == "nodes" and len(rest) >= 2:
154
188
  if rest[-1] == "method" and method == "POST":
155
189
  if not _exec_allowed():
156
- raise PermissionError(
190
+ raise _Forbidden(
157
191
  "Forbidden: arbitrary method calls are disabled (TDMCP_BRIDGE_ALLOW_EXEC=0)."
158
192
  )
193
+ _require(body, "method")
159
194
  return api_service.call_method(
160
195
  _node_path(rest[1:-1]),
161
196
  body["method"],
@@ -263,6 +298,6 @@ def handle(request, response, webserver=None):
263
298
  _emit_event(webserver, method, parsed.path, data)
264
299
  return _send(response, 200, {"ok": True, "data": data})
265
300
  except PermissionError as exc:
266
- return _send(response, 401, {"ok": False, "error": {"message": str(exc)}})
301
+ return _send(response, getattr(exc, "status", 403), {"ok": False, "error": {"message": str(exc)}})
267
302
  except Exception as exc: # noqa: BLE001
268
303
  return _send(response, 400, {"ok": False, "error": {"message": str(exc)}})
@@ -98,6 +98,11 @@ def capture(path, width=640, height=360):
98
98
 
99
99
  w = int(getattr(node, "width", width) or width)
100
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))
101
106
 
102
107
  data = _checkerboard_png(node, w, h)
103
108
  if data is None:
@@ -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,12 @@ 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
+
204
221
 
205
222
  class OriginTests(unittest.TestCase):
206
223
  """The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
@@ -230,9 +247,15 @@ class OriginTests(unittest.TestCase):
230
247
  with self.assertRaises(PermissionError):
231
248
  ac._check_origin({"headers": {"origin": "http://evil.com"}})
232
249
 
233
- def test_handle_rejects_cross_origin_with_401(self):
250
+ def test_handle_rejects_cross_origin_with_403(self):
234
251
  resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
235
- self.assertEqual(resp["statusCode"], 401)
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)
236
259
  self.assertIn("cross-origin", resp["data"])
237
260
 
238
261
 
@@ -263,14 +286,22 @@ class HandleTests(unittest.TestCase):
263
286
  self.assertEqual(resp["statusCode"], 400)
264
287
  self.assertIn("boom", resp["data"])
265
288
 
266
- def test_exec_disabled_surfaces_as_401_with_message(self):
289
+ def test_exec_disabled_surfaces_as_403_with_message(self):
267
290
  os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
268
291
  resp = ac.handle(
269
292
  {"method": "POST", "uri": "/api/exec", "data": '{"script": "1"}'}, self._resp()
270
293
  )
271
- self.assertEqual(resp["statusCode"], 401)
294
+ self.assertEqual(resp["statusCode"], 403)
272
295
  self.assertIn("disabled", resp["data"])
273
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
+
274
305
 
275
306
  if __name__ == "__main__":
276
307
  unittest.main()