Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ claude/
!python/synapse/_vendor/**
python/synapse/_vendor/**/__pycache__/
python/synapse/_vendor/**/*.pyc

# SCOUT reconnaissance artifacts (read-only investigation output)
.scout/

# FORGE build-orchestration artifacts (run logs, specs)
.forge/
12 changes: 10 additions & 2 deletions houdini/python_panels/synapse_panel.pypanel
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,17 @@ class _FallbackClaudeWorker(QtCore.QThread):

def run(self):
try:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
try:
from synapse.host.auth import get_anthropic_api_key
api_key = get_anthropic_api_key() or ""
except Exception:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
if not api_key:
self.stream_error.emit("ANTHROPIC_API_KEY not set in environment")
self.stream_error.emit(
'No Anthropic API key found. Set it at the SYSTEM level and '
'relaunch Houdini: setx ANTHROPIC_API_KEY "sk-ant-..." '
"(a terminal-scoped `set` won't carry into Houdini on Windows)."
)
return

body = {
Expand Down
2 changes: 2 additions & 0 deletions mcp_tools_cops.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
TOOL_NAMES = [
# Foundation
"cops_create_network",
"cops_create_copnet",
"cops_create_node",
"cops_connect",
"cops_set_opencl",
Expand Down Expand Up @@ -55,6 +56,7 @@
# Dispatch entries for this group
DISPATCH_KEYS = {
"cops_create_network": ("cops_create_network", "identity"),
"cops_create_copnet": ("cops_create_copnet", "identity"),
"cops_create_node": ("cops_create_node", "identity"),
"cops_connect": ("cops_connect", "identity"),
"cops_set_opencl": ("cops_set_opencl", "identity"),
Expand Down
8 changes: 7 additions & 1 deletion mcp_tools_usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"houdini_create_usd_prim",
"houdini_modify_usd_prim",
"houdini_reference_usd",
"houdini_set_payload_loadstate",
"houdini_create_point_instancer",
"houdini_shot_render_ready",
"houdini_query_prims",
"houdini_manage_variant_set",
"houdini_manage_collection",
Expand All @@ -47,8 +50,11 @@
"houdini_get_usd_attribute": ("get_usd_attribute", "filter_keys:node,prim_path,attribute_name"),
"houdini_set_usd_attribute": ("set_usd_attribute", "filter_keys:node,prim_path,attribute_name,value"),
"houdini_create_usd_prim": ("create_usd_prim", "filter_keys:node,prim_path,prim_type"),
"houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active"),
"houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active,instanceable"),
"houdini_reference_usd": ("reference_usd", "identity"),
"houdini_set_payload_loadstate": ("set_payload_loadstate", "identity"),
"houdini_create_point_instancer": ("create_point_instancer", "identity"),
"houdini_shot_render_ready": ("shot_render_ready", "identity"),
"houdini_query_prims": ("query_prims", "identity"),
"houdini_manage_variant_set": ("manage_variant_set", "identity"),
"houdini_manage_collection": ("manage_collection", "identity"),
Expand Down
4 changes: 4 additions & 0 deletions python/synapse/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ class CommandType(Enum):
GET_STAGE_INFO = "get_stage_info"
SET_USD_ATTRIBUTE = "set_usd_attribute"
GET_USD_ATTRIBUTE = "get_usd_attribute"
SET_PAYLOAD_LOADSTATE = "set_payload_loadstate"
CREATE_POINT_INSTANCER = "create_point_instancer"
SHOT_RENDER_READY = "shot_render_ready"
QUERY_PRIMS = "query_prims"
MANAGE_VARIANT_SET = "manage_variant_set"
MANAGE_COLLECTION = "manage_collection"
Expand Down Expand Up @@ -101,6 +104,7 @@ class CommandType(Enum):

# Copernicus (COPs) — Foundation
COPS_CREATE_NETWORK = "cops_create_network"
COPS_CREATE_COPNET = "cops_create_copnet"
COPS_CREATE_NODE = "cops_create_node"
COPS_CONNECT = "cops_connect"
COPS_SET_OPENCL = "cops_set_opencl"
Expand Down
61 changes: 59 additions & 2 deletions python/synapse/mcp/_tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,15 @@ def _delete_node_payload(args: dict) -> dict:
False, True, False),

("houdini_modify_usd_prim", "modify_usd_prim",
_filter_keys(("node", "prim_path", "kind", "purpose", "active")),
"Modify USD prim metadata: kind, purpose, or active state.",
_filter_keys(("node", "prim_path", "kind", "purpose", "active", "instanceable")),
"Modify USD prim metadata: kind, purpose, active state, or instanceable flag.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path"},
"kind": {"type": "string", "description": "Model kind"},
"purpose": {"type": "string", "description": "Prim purpose"},
"active": {"type": "boolean", "description": "Whether the prim is active"},
"instanceable": {"type": "boolean", "description": "Set the prim's instanceable flag"},
}, "required": ["prim_path"]},
False, True, False),

Expand Down Expand Up @@ -522,9 +523,56 @@ def _delete_node_payload(args: dict) -> dict:
"mode": {"type": "string", "enum": ["reference", "payload", "sublayer"],
"description": "Import mode: reference (default), payload (deferred load), or sublayer (most Karma-compatible)"},
"parent": {"type": "string", "description": "Parent LOP network path"},
"karma_visible": {"type": "boolean", "description": "Author purpose/kind on the referenced prim for Karma visibility (default: true)"},
"purpose": {"type": "string", "description": "USD purpose to author non-clobberingly (default: default)"},
"kind": {"type": "string", "description": "USD model kind to author non-clobberingly (default: component)"},
}, "required": ["file"]},
False, True, False),

("houdini_set_payload_loadstate", "set_payload_loadstate", _identity,
"Control USD payload load state and prim activation. Load/unload a payload by "
"prim path and/or toggle the prim's active flag. Use to defer-load or release "
"heavy referenced assets.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path carrying the payload"},
"action": {"type": "string", "enum": ["load", "unload"],
"description": "Load or unload the payload (optional)"},
"active": {"type": "boolean", "description": "Set prim active/inactive (optional)"},
}, "required": ["prim_path"]},
False, True, False),

("houdini_create_point_instancer", "create_point_instancer", _identity,
"Author a UsdGeom.PointInstancer: scatter prototype prims across positions. "
"Minimal valid setup -- defines the instancer, sets the prototypes relationship, "
"protoIndices (defaults to zeros), and positions.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path for the PointInstancer"},
"prototypes": {"type": "array", "items": {"type": "string"},
"description": "Prototype prim paths to instance"},
"positions": {"type": "array", "items": {"type": "array", "items": {"type": "number"}},
"description": "Instance positions as [[x,y,z], ...]"},
}, "required": ["prim_path"]},
False, True, False),
Comment on lines +545 to +557
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify that optional prototypes and positions is intentional.

The description states "Minimal valid setup -- defines the instancer, sets the prototypes relationship, protoIndices (defaults to zeros), and positions", which implies these fields will be set. However, the schema makes both prototypes and positions optional (only prim_path is required).

Clarify what happens when these fields are not provided:

  • Does the handler create an empty point instancer?
  • Are there sensible defaults applied?
  • Should the description be updated to reflect that these are optional?

Run the following to verify the handler implementation:

#!/bin/bash
# Search for the create_point_instancer handler implementation
rg -n -A 20 'def _handle_create_point_instancer' --type=py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/synapse/mcp/_tool_registry.py` around lines 545 - 557, The schema for
the "houdini_create_point_instancer" / "create_point_instancer" action marks
"prototypes" and "positions" as optional but the description implies they will
be set; inspect the handler function _handle_create_point_instancer to determine
actual behavior (does it create an empty instancer, apply protoIndices defaults,
or fill positions) and then make the schema and description consistent: either
require "prototypes" and "positions" in the schema if the handler needs them, or
update the description to state that these fields are optional and describe the
defaults/empty-instancer behavior that the handler implements (and if necessary,
modify _handle_create_point_instancer to apply sensible defaults such as empty
prototypes and zeroed protoIndices when fields are missing).


("houdini_shot_render_ready", "shot_render_ready", _identity,
"Composite orchestrator: get a shot render-ready in one call. Runs "
"create_textured_material -> solaris_assemble_chain -> safe_render in sequence, "
"threading outputs, and returns a per-step summary with any errors. Orchestrates "
"existing primitives -- does not re-implement them.",
{"type": "object", "properties": {
"diffuse_map": {"type": "string", "description": "Diffuse/albedo texture for the material step (optional)"},
"material_name": {"type": "string", "description": "Material name (optional)"},
"geo_pattern": {"type": "string", "description": "Geometry prim pattern to assign the material to (optional)"},
"parent": {"type": "string", "description": "LOP network path for assembly (default: /stage)"},
"rop_path": {"type": "string", "description": "Render ROP path (auto-discovers if omitted)"},
"width": {"type": "integer", "description": "Render width override"},
"height": {"type": "integer", "description": "Render height override"},
"skip_render": {"type": "boolean", "description": "Assemble only, skip the render step (default: false)"},
}, "required": []},
False, True, False),

("houdini_query_prims", "query_prims", _identity,
"Query USD stage prims with filtering by type, purpose, and name pattern. "
"Returns matching prims with their paths, types, and metadata.",
Expand Down Expand Up @@ -1019,6 +1067,15 @@ def _delete_node_payload(args: dict) -> dict:
}, "required": []},
False, True, False),

("cops_create_copnet", "cops_create_copnet", _identity,
"Create a modern Copernicus 'copnet' network (H21 Copernicus, distinct from legacy cop2net).",
{"type": "object", "properties": {
"parent": {"type": "string", "description": "Parent node path (default: /obj)"},
"name": {"type": "string", "description": "Network name (default: copnet)"},
"starter": {"type": "string", "description": "Optional COP node type to create inside the new copnet"},
}, "required": []},
False, True, False),

("cops_create_node", "cops_create_node", _identity,
"Create a COP node inside a COP network.",
{"type": "object", "properties": {
Expand Down
21 changes: 16 additions & 5 deletions python/synapse/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ def _dumps(obj) -> bytes:

logger = logging.getLogger("synapse.mcp")


def _isError_text(result: dict) -> str:
"""Extract a human-readable error message from a dispatch_tool isError result."""
content = result.get("content", [])
if content and isinstance(content[0], dict):
return content[0].get("text", "Unknown error")
return "Unknown error"

# SSE event types
SSE_RENDER_PROGRESS = "synapse/render_progress"
SSE_GATE_REQUEST = "synapse/gate_request"
Expand Down Expand Up @@ -423,7 +431,13 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) ->
)
except ImportError:
result = dispatch_tool(handler, tool_name, arguments)
if self._circuit_breaker and not (isinstance(result, dict) and result.get("isError")):
# Propagate tool errors to the JSON-RPC layer here too — read-only
# tools skip resilience, but a handler failure must still surface as
# a JSON-RPC error rather than a success result with isError buried
# inside (matches the non-read-only path below).
if isinstance(result, dict) and result.get("isError"):
raise JsonRpcError(INTERNAL_ERROR, _isError_text(result))
if self._circuit_breaker:
self._circuit_breaker.record_success()
return result

Expand Down Expand Up @@ -486,10 +500,7 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) ->

# Propagate tool errors to JSON-RPC layer so MCP clients detect failures
if isinstance(result, dict) and result.get("isError"):
error_text = "Unknown error"
content = result.get("content", [])
if content and isinstance(content[0], dict):
error_text = content[0].get("text", error_text)
error_text = _isError_text(result)
# Only record infrastructure failures for circuit breaker
# (not user errors like "node not found")
if any(k in error_text.lower() for k in ("timeout", "main thread", "crashed", "unresponsive")):
Expand Down
1 change: 1 addition & 0 deletions python/synapse/panel/bridge_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ def _dispatch(*_args, **_kwargs):
from synapse.core.protocol import SynapseResponse
integrity_dict = result.integrity.to_dict() if result.integrity else {}
return SynapseResponse(
id=command.id,
success=False,
error="Bridge: {}".format(result.error or "Unknown error"),
data={"_integrity": integrity_dict},
Expand Down
17 changes: 14 additions & 3 deletions python/synapse/panel/claude_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import http.client
import json
import logging
import os
import ssl
from typing import Optional

Expand Down Expand Up @@ -91,9 +90,21 @@ def get_messages(self) -> list[dict]:
def run(self) -> None:
"""Entry point executed on the background thread."""
try:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
# Resolve via the canonical auth layer: hou.secure (where available)
# then the ANTHROPIC_API_KEY env var, whitespace-stripped, never
# raising. Avoids the old raw os.environ read that missed keys stored
# in hou.secure and surfaced a cryptic message.
from ..host.auth import get_anthropic_api_key
api_key = get_anthropic_api_key()
if not api_key:
self.stream_error.emit("ANTHROPIC_API_KEY not set in environment")
self.stream_error.emit(
"No Anthropic API key found. Set it at the SYSTEM level so "
"Houdini inherits it, then relaunch Houdini: "
'setx ANTHROPIC_API_KEY "sk-ant-..." '
"(a terminal-scoped `set` won't carry into Houdini on Windows). "
"On builds exposing hou.secure you can instead run, in Houdini's "
"Python shell: hou.secure.setPassword('synapse_anthropic', 'sk-ant-...')."
)
return

self._conversation_loop(api_key)
Expand Down
8 changes: 8 additions & 0 deletions python/synapse/server/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
"modify_usd_prim": AuditCategory.PIPELINE,
"set_usd_attribute": AuditCategory.PIPELINE,
"reference_usd": AuditCategory.PIPELINE,
"set_payload_loadstate": AuditCategory.PIPELINE,
"create_point_instancer": AuditCategory.PIPELINE,
"shot_render_ready": AuditCategory.RENDER,
"query_prims": AuditCategory.PIPELINE,
"manage_variant_set": AuditCategory.PIPELINE,
"manage_collection": AuditCategory.PIPELINE,
Expand Down Expand Up @@ -139,6 +142,7 @@
"hda_list": AuditCategory.PIPELINE,
# Copernicus (COPs) — Foundation
"cops_create_network": AuditCategory.PIPELINE,
"cops_create_copnet": AuditCategory.PIPELINE,
"cops_create_node": AuditCategory.PIPELINE,
"cops_connect": AuditCategory.PIPELINE,
"cops_set_opencl": AuditCategory.PIPELINE,
Expand Down Expand Up @@ -419,6 +423,9 @@ def _register_handlers(self):

# USD scene assembly (reference / sublayer / payload) + prim queries
reg.register("reference_usd", self._handle_reference_usd)
reg.register("set_payload_loadstate", self._handle_set_payload_loadstate)
reg.register("create_point_instancer", self._handle_create_point_instancer)
reg.register("shot_render_ready", self._handle_shot_render_ready)
reg.register("query_prims", self._handle_query_prims)
reg.register("manage_variant_set", self._handle_manage_variant_set)
reg.register("manage_collection", self._handle_manage_collection)
Expand Down Expand Up @@ -501,6 +508,7 @@ def _register_handlers(self):

# Copernicus (COPs) — Foundation
reg.register("cops_create_network", self._handle_cops_create_network)
reg.register("cops_create_copnet", self._handle_cops_create_copnet)
reg.register("cops_create_node", self._handle_cops_create_node)
reg.register("cops_connect", self._handle_cops_connect)
reg.register("cops_set_opencl", self._handle_cops_set_opencl)
Expand Down
65 changes: 65 additions & 0 deletions python/synapse/server/handlers_cops.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,71 @@ def _on_main():

return run_on_main(_on_main)

def _handle_cops_create_copnet(self, payload: Dict) -> Dict:
"""Create a modern Copernicus 'copnet' network container.

Distinct from the legacy 'cop2net' built by _handle_cops_create_network:
H21 Copernicus uses the 'copnet' node type. This is the foundational
modern-Copernicus surface — all 20 existing cops_* tools build on the
legacy cop2net; this adds the modern container without rewriting them.

Payload:
parent (str): Parent node path (default: '/obj' — mirrors
_handle_cops_create_network, a container creator).
name (str): Network name (default: 'copnet').
starter (str): Optional single COP node type to create inside the
new copnet so the network is non-empty (mirrors the
initial_nodes pattern of create_network, single-node form).

Returns:
Dict with network path, type name, and optional starter node path.

Note:
Behavioral verification (does the copnet cook through Karma XPU)
is DEFERRED — the live bridge is down. This handler only builds
the network; it intentionally performs no cook() calls.
"""
if not HOU_AVAILABLE:
raise RuntimeError(_HOUDINI_UNAVAILABLE)

parent_path = resolve_param_with_default(payload, "parent", "/obj")
name = resolve_param_with_default(payload, "name", "copnet")
starter = resolve_param_with_default(payload, "starter", None)

from .main_thread import run_on_main

def _on_main():
parent = hou.node(parent_path)
if parent is None:
raise ValueError(
f"Couldn't find parent node '{parent_path}' -- "
"check the path and try again"
)

with hou.undos.group("synapse_cops_create_copnet"):
network = parent.createNode("copnet", name)
if network is None:
raise RuntimeError(
"Couldn't create copnet -- "
"make sure Copernicus is available in your Houdini build"
)
network.moveToGoodPosition()

starter_path = None
if starter:
child = network.createNode(str(starter))
if child is not None:
child.moveToGoodPosition()
starter_path = child.path()

return {
"network_path": network.path(),
"type": network.type().name(),
"starter_node": starter_path,
}

return run_on_main(_on_main)

def _handle_cops_create_node(self, payload: Dict) -> Dict:
"""Create a COP node inside a COP network.

Expand Down
Loading
Loading