@dpantani/tdmcp 0.5.0 → 0.6.1

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,11 +1,15 @@
1
1
  {
2
2
  "name": "@dpantani/tdmcp",
3
3
  "mcpName": "io.github.Pantani/tdmcp",
4
- "version": "0.5.0",
4
+ "version": "0.6.1",
5
5
  "description": "tdmcp — the TouchDesigner MCP server. Build real TouchDesigner visual systems from plain language with Claude, Cursor, or Codex (Model Context Protocol).",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "homepage": "https://pantani.github.io/tdmcp/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Pantani/tdmcp.git"
12
+ },
9
13
  "engines": {
10
14
  "node": ">=20"
11
15
  },
@@ -25,6 +29,7 @@
25
29
  "dist",
26
30
  "scripts/fetch-shader-park-td.mjs",
27
31
  "scripts/tdmcp-lops.mjs",
32
+ "safeskill.manifest.json",
28
33
  "recipes",
29
34
  "td/bootstrap.py",
30
35
  "td/modules",
@@ -57,7 +62,7 @@
57
62
  "docs:dev": "npm run docs:gen && vitepress dev docs",
58
63
  "docs:build": "npm run docs:gen && vitepress build docs",
59
64
  "docs:preview": "vitepress preview docs",
60
- "version": "node scripts/sync-manifest-version.mjs && git add dxt/manifest.json",
65
+ "version": "node scripts/sync-manifest-version.mjs && git add dxt/manifest.json safeskill.manifest.json",
61
66
  "prepack": "node scripts/clean-pycache.mjs",
62
67
  "prepublishOnly": "npm run build && npm test"
63
68
  },
@@ -99,5 +104,19 @@
99
104
  "typescript": "^6.0.3",
100
105
  "vitepress": "^1.6.4",
101
106
  "vitest": "^4.1.7"
107
+ },
108
+ "overrides": {
109
+ "vitepress": {
110
+ "vite": "5.4.21"
111
+ },
112
+ "vitest": {
113
+ "vite": "6.4.2"
114
+ },
115
+ "@vitest/mocker": {
116
+ "vite": "6.4.2"
117
+ },
118
+ "vite-node": {
119
+ "vite": "6.4.2"
120
+ }
102
121
  }
103
122
  }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@dpantani/tdmcp",
3
+ "version": "0.6.1",
4
+ "summary": "TouchDesigner MCP server for local creative-control workflows.",
5
+ "expectedCapabilities": {
6
+ "filesystem": {
7
+ "read": [
8
+ "project recipes, docs, package manifests and optional user-selected vault/package folders",
9
+ "tdmcp config files from the current project or user config directory"
10
+ ],
11
+ "write": [
12
+ "generated bridge files under the user's selected install directory",
13
+ "optional user-selected vault notes and exported media"
14
+ ]
15
+ },
16
+ "network": {
17
+ "loopback": [
18
+ "TouchDesigner bridge on the configured host and port",
19
+ "optional local LLM endpoint for tdmcp chat"
20
+ ],
21
+ "remote": [
22
+ "GitHub release/package downloads when the user installs optional library packages",
23
+ "OpenAI-compatible LLM endpoint only when the user configures a remote base URL"
24
+ ]
25
+ },
26
+ "process": [
27
+ "opens the local chat UI in a browser",
28
+ "starts local Ollama only when auto-start is enabled and the endpoint is local",
29
+ "uses the platform zip utility when extracting user-requested package archives"
30
+ ]
31
+ },
32
+ "securityNotes": [
33
+ "Secrets are loaded from explicit environment/config fields and redacted before diagnostic printing.",
34
+ "Archive extraction validates paths and rejects traversal or symlink entries before unpacking.",
35
+ "Raw Python tools can be disabled with TDMCP_RAW_PYTHON=off or hidden with TDMCP_TOOL_PROFILE=safe."
36
+ ]
37
+ }
package/td/README.md CHANGED
@@ -19,7 +19,7 @@ idempotent, and can be undone with `from mcp import install; install.uninstall()
19
19
  (`Dialogs → Textport and DATs`):
20
20
 
21
21
  ```python
22
- import urllib.request; exec(urllib.request.urlopen("https://raw.githubusercontent.com/Pantani/tdmcp/main/td/bootstrap.py").read().decode())
22
+ import urllib.request; exec(urllib.request.urlopen("https://github.com/Pantani/tdmcp/raw/main/td/bootstrap.py").read().decode())
23
23
  ```
24
24
 
25
25
  It downloads the bridge to `~/tdmcp-bridge/modules` and starts it on port 9980.
package/td/bootstrap.py CHANGED
@@ -3,7 +3,7 @@
3
3
  Paste this single line into the Textport (Dialogs -> Textport and DATs) and the
4
4
  bridge installs itself and starts:
5
5
 
6
- import urllib.request; exec(urllib.request.urlopen("https://raw.githubusercontent.com/Pantani/tdmcp/main/td/bootstrap.py").read().decode())
6
+ import urllib.request; exec(urllib.request.urlopen("https://github.com/Pantani/tdmcp/raw/main/td/bootstrap.py").read().decode())
7
7
 
8
8
  It downloads just the bridge modules to ~/tdmcp-bridge/modules, puts them on
9
9
  sys.path for this session, and runs install.run() -> a tdmcp_bridge on port 9980.
@@ -15,6 +15,7 @@ private, point REPO_ZIP at a public release asset, or use `install-bridge`
15
15
 
16
16
  import io
17
17
  import os
18
+ import stat
18
19
  import sys
19
20
  import zipfile
20
21
  import urllib.request
@@ -22,6 +23,37 @@ import urllib.request
22
23
  REPO_ZIP = "https://github.com/Pantani/tdmcp/archive/refs/heads/main.zip"
23
24
  DEST = os.path.expanduser("~/tdmcp-bridge")
24
25
  _MARKER = "/td/modules/"
26
+ _SKIP_RUN_ENV = "TDMCP_BOOTSTRAP_SKIP_RUN"
27
+
28
+
29
+ def _is_symlink(info):
30
+ return stat.S_ISLNK((info.external_attr >> 16) & 0o170000)
31
+
32
+
33
+ def _safe_module_path(name, modules_dir):
34
+ idx = name.find(_MARKER)
35
+ if idx == -1:
36
+ return None
37
+
38
+ rel = name[idx + len(_MARKER):].replace("\\", "/")
39
+ if not rel or rel.endswith("/"):
40
+ return None
41
+
42
+ parts = rel.split("/")
43
+ if (
44
+ rel.startswith("/")
45
+ or rel.startswith("\\")
46
+ or (len(parts[0]) >= 2 and parts[0][1] == ":")
47
+ or any(part in ("", ".", "..") for part in parts)
48
+ ):
49
+ raise RuntimeError("[tdmcp] Refusing unsafe archive entry: %s" % name)
50
+
51
+ root = os.path.realpath(modules_dir)
52
+ target = os.path.realpath(os.path.join(modules_dir, *parts))
53
+ if target != root and not target.startswith(root + os.sep):
54
+ raise RuntimeError("[tdmcp] Refusing archive entry outside modules: %s" % name)
55
+
56
+ return target
25
57
 
26
58
 
27
59
  def fetch_modules(repo_zip=REPO_ZIP, dest=DEST):
@@ -39,16 +71,15 @@ def fetch_modules(repo_zip=REPO_ZIP, dest=DEST):
39
71
  zf = zipfile.ZipFile(io.BytesIO(data))
40
72
  os.makedirs(modules_dir, exist_ok=True)
41
73
  extracted = 0
42
- for name in zf.namelist():
74
+ for info in zf.infolist():
75
+ name = info.filename
43
76
  if name.endswith("/"):
44
77
  continue
45
- idx = name.find(_MARKER)
46
- if idx == -1:
47
- continue
48
- rel = name[idx + len(_MARKER):]
49
- if not rel:
78
+ target = _safe_module_path(name, modules_dir)
79
+ if target is None:
50
80
  continue
51
- target = os.path.join(modules_dir, rel)
81
+ if _is_symlink(info):
82
+ raise RuntimeError("[tdmcp] Refusing symlink archive entry: %s" % name)
52
83
  os.makedirs(os.path.dirname(target), exist_ok=True)
53
84
  with zf.open(name) as src, open(target, "wb") as out:
54
85
  out.write(src.read())
@@ -70,4 +101,5 @@ def run(repo_zip=REPO_ZIP, dest=DEST, port=9980):
70
101
 
71
102
 
72
103
  # Running via exec(urlopen(...).read()) or as a script kicks off the install.
73
- run()
104
+ if os.environ.get(_SKIP_RUN_ENV) != "1":
105
+ run()
@@ -13,7 +13,15 @@ import os
13
13
  from urllib.parse import parse_qs, unquote, urlparse
14
14
 
15
15
  from mcp import events
16
- from mcp.services import analysis_service, api_service, batch_service, preview_service
16
+ from mcp.services import (
17
+ analysis_service,
18
+ api_service,
19
+ batch_service,
20
+ connect_service,
21
+ log_service,
22
+ param_text_service,
23
+ preview_service,
24
+ )
17
25
 
18
26
 
19
27
  class _Unauthorized(PermissionError):
@@ -158,7 +166,23 @@ def _require(body, *keys):
158
166
  raise ValueError("Missing required field(s): %s." % ", ".join(missing))
159
167
 
160
168
 
161
- def _route(method, path, query, body):
169
+ def _bridge_error_log_path(webserver):
170
+ """Resolve the installed Error DAT's path from the webserver that serves this
171
+ request. The API is served by the webserver DAT INSIDE the bridge container, so
172
+ the Error DAT is ``webserver.parent().op('error_log')`` regardless of a custom
173
+ ``parent_path``/``container``. Returns None when it can't be resolved (e.g. the
174
+ webserver isn't threaded, as in tests) so the caller falls back to the default."""
175
+ if webserver is None:
176
+ return None
177
+ try:
178
+ bridge = webserver.parent()
179
+ ed = bridge.op("error_log") if bridge is not None else None
180
+ return ed.path if ed is not None else None
181
+ except Exception: # noqa: BLE001
182
+ return None
183
+
184
+
185
+ def _route(method, path, query, body, webserver=None):
162
186
  parts = [p for p in path.split("/") if p]
163
187
  if not parts or parts[0] != "api":
164
188
  raise ValueError("Not found: %s" % path)
@@ -184,6 +208,36 @@ def _route(method, path, query, body):
184
208
  if rest == ["batch"] and method == "POST":
185
209
  return batch_service.run(body.get("operations", []))
186
210
 
211
+ # Structured wiring + logs endpoints — NO exec gate (they must survive
212
+ # TDMCP_BRIDGE_ALLOW_EXEC=0). Top-level paths, so no collision with nodes/network.
213
+ if rest == ["connect"] and method == "POST":
214
+ _require(body, "source_path", "target_path")
215
+ return connect_service.connect(
216
+ body["source_path"],
217
+ body["target_path"],
218
+ int(body.get("source_output", 0)),
219
+ int(body.get("target_input", 0)),
220
+ )
221
+ if rest == ["disconnect"] and method == "POST":
222
+ _require(body, "to_path")
223
+ return connect_service.disconnect(
224
+ body["to_path"], body.get("from_path"), body.get("to_input")
225
+ )
226
+ if rest == ["logs"] and method == "GET":
227
+ # Resolve the Error DAT relative to the webserver's own container so a custom
228
+ # install (parent_path/container) works; fall back to get_logs' default when
229
+ # the webserver isn't threaded (e.g. tests).
230
+ log_kwargs = {}
231
+ error_dat = _bridge_error_log_path(webserver)
232
+ if error_dat:
233
+ log_kwargs["error_dat_path"] = error_dat
234
+ return log_service.get_logs(
235
+ _qs(query, "severity", "all"),
236
+ int(_qs(query, "max_lines", 200)),
237
+ _qs(query, "scope") or None,
238
+ **log_kwargs,
239
+ )
240
+
187
241
  if rest[0] == "nodes" and len(rest) >= 2:
188
242
  if rest[-1] == "method" and method == "POST":
189
243
  if not _exec_allowed():
@@ -199,6 +253,36 @@ def _route(method, path, query, body):
199
253
  )
200
254
  if rest[-1] == "errors" and method == "GET":
201
255
  return api_service.get_node_errors(_node_path(rest[1:-1]), recursive=False)
256
+ # Param-mode + DAT-text suffixes — MORE SPECIFIC than the generic node CRUD
257
+ # below, so they MUST be matched first (else `…/text` GET is swallowed by
258
+ # get_node). No exec gate — structured endpoints survive ALLOW_EXEC=0.
259
+ if rest[-1] == "params" and method == "GET" and _qs(query, "modes") == "true":
260
+ return param_text_service.read_param_modes(
261
+ _node_path(rest[1:-1]),
262
+ (_qs(query, "keys").split(",") if _qs(query, "keys") else None),
263
+ _qs(query, "non_default_only") == "true",
264
+ )
265
+ if len(rest) >= 4 and rest[-1] == "mode" and rest[-3] == "params" and method == "PATCH":
266
+ # /api/nodes/<path…>/params/<param>/mode
267
+ return param_text_service.set_param_mode(
268
+ _node_path(rest[1:-3]),
269
+ unquote(rest[-2]),
270
+ body.get("mode", "expression"),
271
+ body.get("expr"),
272
+ body.get("value"),
273
+ )
274
+ if rest[-1] == "text" and method == "GET":
275
+ # Disambiguate a node literally named "text": the /text suffix only means
276
+ # "read this DAT's text" when the PARENT is actually a DAT. Otherwise the
277
+ # WebServer DAT decoded the path's slashes and "text" is the node's own
278
+ # name, so return that node's detail instead of the parent's DAT text.
279
+ _txt_parent = _node_path(rest[1:-1])
280
+ if param_text_service.is_dat(_txt_parent):
281
+ return param_text_service.get_dat_text(_txt_parent)
282
+ return api_service.get_node(_node_path(rest[1:]))
283
+ if rest[-1] == "text" and method == "PUT":
284
+ _require(body, "text")
285
+ return param_text_service.put_dat_text(_node_path(rest[1:-1]), body["text"])
202
286
  node_path = _node_path(rest[1:])
203
287
  if method == "GET":
204
288
  return api_service.get_node(node_path)
@@ -294,7 +378,7 @@ def handle(request, response, webserver=None):
294
378
  parsed = urlparse(request.get("uri", "/"))
295
379
  query = _merge_query(request, parse_qs(parsed.query))
296
380
  body = _parse_body(request)
297
- data = _route(method, parsed.path, query, body)
381
+ data = _route(method, parsed.path, query, body, webserver)
298
382
  _emit_event(webserver, method, parsed.path, data)
299
383
  return _send(response, 200, {"ok": True, "data": data})
300
384
  except PermissionError as exc:
@@ -70,6 +70,27 @@ def _event_hooks_source(modules_dir=None):
70
70
  " emitted += 1\n"
71
71
  " if emitted >= 10:\n"
72
72
  " break\n"
73
+ " # Edge-triggered cook.error / error.cleared from the bridge Error DAT.\n"
74
+ " # Broadcast on a row-count delta OR a newest-row identity change. At\n"
75
+ " # the maxlines cap, clamp replaces old rows so numRows stops growing —\n"
76
+ " # tracking the newest (absframe, message) too keeps cook.error firing\n"
77
+ " # for fresh errors after the buffer fills (still <=1 per frame).\n"
78
+ " _ed = me.parent().op('error_log')\n"
79
+ " if _ed is not None and _ed.numRows > 0:\n"
80
+ " _rows = _ed.numRows - 1 # data rows, minus header\n"
81
+ " _prev = getattr(me, '_tdmcp_err_rows', 0)\n"
82
+ " _prev_new = getattr(me, '_tdmcp_err_newest', None)\n"
83
+ " _new = (str(_ed[_rows, 2]), str(_ed[_rows, 1])) if _rows > 0 else None\n"
84
+ " if _rows != _prev or _new != _prev_new:\n"
85
+ " me._tdmcp_err_rows = _rows\n"
86
+ " me._tdmcp_err_newest = _new\n"
87
+ " if _rows == 0:\n"
88
+ " _broadcast('error.cleared', {'count': 0})\n"
89
+ " else:\n"
90
+ " _broadcast('cook.error', {\n"
91
+ " 'source': str(_ed[_rows, 0]), 'message': str(_ed[_rows, 1]),\n"
92
+ " 'severity': str(_ed[_rows, 4]), 'type': str(_ed[_rows, 5]), 'count': _rows,\n"
93
+ " })\n"
73
94
  " except Exception:\n"
74
95
  " pass\n\n"
75
96
  "def onProjectPostSave():\n"
@@ -82,7 +103,14 @@ def _event_hooks_source(modules_dir=None):
82
103
  )
83
104
 
84
105
 
85
- def run(port=9980, parent_path="/project1", container="tdmcp_bridge", modules_dir=None, export_tox=None):
106
+ def run(
107
+ port=9980,
108
+ parent_path="/project1",
109
+ container="tdmcp_bridge",
110
+ modules_dir=None,
111
+ export_tox=None,
112
+ error_scope=None,
113
+ ):
86
114
  import td # TouchDesigner globals are only available via the td module here
87
115
 
88
116
  if modules_dir:
@@ -109,6 +137,26 @@ def run(port=9980, parent_path="/project1", container="tdmcp_bridge", modules_di
109
137
  hooks.par.frameend = True
110
138
  hooks.par.projectpostsave = True
111
139
 
140
+ # Error DAT: structured cook-error/warning capture for GET /api/logs + the
141
+ # edge-triggered cook.error / error.cleared events. Idempotent like the rest.
142
+ # The path /<container>/error_log must stay in sync with log_service.get_logs'
143
+ # error_dat_path default and getBridgeLogs' fallback assumption.
144
+ err = comp.op("error_log") or comp.create(td.errorDAT, "error_log")
145
+ err.par.active = True
146
+ err.par.maxlines = 200 # default 10 is too small for a show
147
+ err.par.clamp = True # keep newest within maxlines
148
+ err.par.severity = "*" # capture errors AND warnings
149
+ err.par.source = "*"
150
+ try:
151
+ # Watch the artist's whole network by default (not just the bridge container).
152
+ # Set the VALUE directly (a constant op path) — assigning .expr alone would
153
+ # not switch the par into Expression mode, so it would keep its default/
154
+ # constant fromop and never watch parent_path/error_scope.
155
+ scope = error_scope or parent_path
156
+ err.par.fromop.val = scope
157
+ except Exception:
158
+ pass
159
+
112
160
  if export_tox:
113
161
  comp.save(export_tox)
114
162
 
@@ -98,6 +98,58 @@ def get_nodes(parent_path=None):
98
98
  return {"nodes": [node_ref(c) for c in children]}
99
99
 
100
100
 
101
+ def _flags(node):
102
+ out = {}
103
+ for attr in ("bypass", "render", "display", "lock", "allowCooking", "cloneImmune"):
104
+ try:
105
+ v = getattr(node, attr)
106
+ if isinstance(v, bool):
107
+ out[attr] = v
108
+ except Exception: # noqa: BLE001
109
+ pass
110
+ # clone is COMP-only and lives on .par.clone (path to master), NOT op.clone.
111
+ try:
112
+ if hasattr(node, "isClone"):
113
+ out["is_clone"] = bool(node.isClone)
114
+ except Exception: # noqa: BLE001
115
+ pass
116
+ try:
117
+ cp = getattr(node.par, "clone", None)
118
+ if cp is not None:
119
+ cv = cp.eval()
120
+ out["clone"] = str(cv) if cv else None
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ return out
124
+
125
+
126
+ def _indexed_inputs(node):
127
+ # Faithful, index-aware: iterate inputConnectors (NOT node.inputs, which omits empty
128
+ # slots). Each wire => {in_index, from, out_index}. Multi-input TOPs pack contiguously,
129
+ # so the indices reported are the live/current ones.
130
+ wires = []
131
+ try:
132
+ for ic in node.inputConnectors:
133
+ try:
134
+ in_index = ic.index
135
+ except Exception: # noqa: BLE001
136
+ in_index = None
137
+ try:
138
+ conns = list(ic.connections)
139
+ except Exception: # noqa: BLE001
140
+ conns = []
141
+ for oc in conns:
142
+ try:
143
+ wires.append(
144
+ {"in_index": in_index, "from": oc.owner.path, "out_index": oc.index}
145
+ )
146
+ except Exception: # noqa: BLE001
147
+ pass
148
+ except Exception: # noqa: BLE001
149
+ pass
150
+ return wires
151
+
152
+
101
153
  def node_detail(node):
102
154
  pars = {}
103
155
  try:
@@ -112,6 +164,36 @@ def node_detail(node):
112
164
  outputs = [c.path for c in getattr(node, "outputs", []) if c]
113
165
  detail = node_ref(node)
114
166
  detail.update({"parameters": pars, "inputs": inputs, "outputs": outputs})
167
+ # --- NEW (node_flags_in_detail): flags + index-aware wiring + position/comment/color/tags ---
168
+ detail["flags"] = _flags(node)
169
+ detail["wires_in"] = _indexed_inputs(node)
170
+ # op.errors() returns a STRING (not a list) — wrap it so a multi-line message is
171
+ # ONE entry, never iterated char-by-char. Lets get_td_node_flags' REST path flag
172
+ # "cook error" suspects (and honor only_problems) the same as the exec walk.
173
+ try:
174
+ _err = node.errors(recurse=False)
175
+ detail["errors"] = [str(_err)] if _err else []
176
+ except Exception: # noqa: BLE001
177
+ pass
178
+ try:
179
+ detail["nodeX"] = node.nodeX
180
+ detail["nodeY"] = node.nodeY
181
+ except Exception: # noqa: BLE001
182
+ pass
183
+ try:
184
+ if node.comment:
185
+ detail["comment"] = node.comment
186
+ except Exception: # noqa: BLE001
187
+ pass
188
+ try:
189
+ detail["color"] = list(node.color) # tuple -> JSON list
190
+ except Exception: # noqa: BLE001
191
+ pass
192
+ try:
193
+ if node.tags:
194
+ detail["tags"] = sorted(str(t) for t in node.tags) # set -> sorted list
195
+ except Exception: # noqa: BLE001
196
+ pass
115
197
  return detail
116
198
 
117
199