@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/README.md +86 -343
- package/dist/cli/agent.d.ts +115 -2
- package/dist/cli/agent.js +10106 -333
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +13261 -3602
- package/dist/index.js.map +1 -1
- package/package.json +21 -3
- package/td/README.md +1 -1
- package/td/modules/mcp/controllers/api_controller.py +42 -7
- package/td/modules/mcp/services/preview_service.py +5 -0
- package/td/modules/utils/version.py +1 -1
- package/td/tests/test_api_controller.py +35 -4
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dpantani/tdmcp",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
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.
|
|
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
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
@@ -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
|
|
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"],
|
|
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
|
|
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"],
|
|
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()
|