Skip to content

Authoring a DCC Host Adapter

Audience: anyone building a DCC integration repo (dcc-mcp-blender, dcc-mcp-maya, dcc-mcp-photoshop, dcc-mcp-unreal, or a new one).

TL;DR: subclass dcc_mcp_core.host.HostAdapter, fill in 3 methods, wire one entry-point. The base class owns the rest — lifecycle, context-manager, adaptive tick intervals, and the interactive/background split.

This guide assumes you already understand why main-thread affinity matters — if not, start with dcc-thread-safety.md.

The 3-hook contract

HostAdapter requires exactly three methods on every subclass.

HookPurposeWhen called
is_background() -> boolIs the DCC running headless?Once per start() call
attach_tick(tick_fn)Register tick_fn with the DCC's native idle primitiveOnce, during start() in interactive mode
detach_tick()Undo attach_tick — must be idempotentDuring stop()

You do not override start, stop, run_headless, is_running, __enter__, or __exit__. Those orchestrate the 3 hooks and must stay consistent across every adapter so callers can treat them interchangeably (LSP).

Minimal subclass

python
from dcc_mcp_core.host import HostAdapter


class BlenderHost(HostAdapter):
    def is_background(self) -> bool:
        import bpy
        return bpy.app.background

    def attach_tick(self, tick_fn):
        import bpy
        # Returning ``tick_fn`` reuses the same callable every time the
        # timer fires, so `detach_tick` can find and unregister it.
        bpy.app.timers.register(tick_fn, first_interval=0.0, persistent=True)
        self._tick_fn = tick_fn

    def detach_tick(self) -> None:
        import bpy
        fn = getattr(self, "_tick_fn", None)
        if fn is not None and bpy.app.timers.is_registered(fn):
            bpy.app.timers.unregister(fn)
        self._tick_fn = None

Done. That's the whole adapter. Everything else — panic handling, dispatcher shutdown on stop, the "wait up to 5s for the headless thread to join" safeguard, the adaptive interval that returns 0s when the queue is hot and 0.5s when it's idle — is in the base.

Wiring it into an MCP server

The adapter drives the dispatcher; it doesn't own it. Your entry point owns both:

python
from dcc_mcp_core import McpHttpConfig, McpHttpServer, ToolRegistry
from dcc_mcp_core.host import BlockingDispatcher

# 1. Build the server.
reg = ToolRegistry()
cfg = McpHttpConfig(port=18765, server_name="blender")
server = McpHttpServer(reg, cfg)

# 2. Create a dispatcher. BlockingDispatcher is right for --background
#    DCCs; QueueDispatcher is right for GUI sessions. Either one is
#    accepted by HostAdapter, McpHttpServer.attach_dispatcher, and
#    StandaloneHost (LSP in practice). If you need a type-only
#    contract for custom dispatchers, import the public
#    TickableDispatcher protocol from dcc_mcp_core.host; do not import
#    private host protocol modules directly.
dispatcher = BlockingDispatcher()
server.attach_dispatcher(dispatcher)

# 3. Start the server. This returns immediately — it only binds the
#    port and spawns the tokio runtime.
handle = server.start()

# 4. Drive the dispatcher with your adapter.
host = BlenderHost(dispatcher)
if host.is_background():
    host.run_headless()   # blocks until shutdown
else:
    host.start()          # non-blocking; returns immediately

Every tools/call that arrives on the HTTP port will now be posted into the dispatcher and executed on whatever thread drives host._tick — i.e. the DCC main thread in interactive mode, or the run_headless thread in headless mode. Handlers never see a tokio worker thread.

Maya example

python
class MayaHost(HostAdapter):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._script_job = None

    def is_background(self) -> bool:
        import maya.cmds as cmds
        return cmds.about(batch=True)

    def attach_tick(self, tick_fn):
        import maya.cmds as cmds
        # `idleEvent` fires on the UI idle tick — native main-thread.
        # Wrap in a lambda so `tick_fn`'s return value is discarded
        # (scriptJob doesn't care about the next interval).
        self._script_job = cmds.scriptJob(
            idleEvent=lambda: tick_fn(),
        )

    def detach_tick(self) -> None:
        import maya.cmds as cmds
        if self._script_job is not None and cmds.scriptJob(
            exists=self._script_job,
        ):
            cmds.scriptJob(kill=self._script_job)
        self._script_job = None

Maya's idleEvent fires more aggressively than Blender's timer, so the default tick_interval_idle=0.5 is conservative enough. If you find the CPU usage too high, bump tick_interval_idle to 1.0.

Headless-only DCCs (ExtendScript, MaxScript)

When the DCC has no Python-callable idle primitive (Adobe Photoshop's ExtendScript, 3ds Max pre-2022's MAXScript bridge, …), run the whole thing headlessly:

python
class PhotoshopHost(HostAdapter):
    def is_background(self) -> bool:
        return True  # always headless — no ExtendScript UI idle hook

    def attach_tick(self, tick_fn):
        # Never called (is_background is always True).
        raise NotImplementedError(
            "PhotoshopHost is always headless; run_headless is the only path",
        )

    def detach_tick(self) -> None:
        pass  # no-op; nothing was attached

Your entry point then calls host.run_headless() unconditionally.

Substitutability test

Every well-behaved subclass should pass the same contract test, which is essentially what tests/test_host_adapter.py::test_subclass_overriding_hooks_drives_dispatcher already exercises on a fake subclass. Copy it into your repo, swap in your real subclass, and you have a CI gate:

python
def test_my_host_drives_dispatcher(live_dcc_fixture):
    dispatcher = QueueDispatcher()
    host = MyDccHost(dispatcher)
    with host:
        result = dispatcher.post(lambda: 42).wait(timeout=5.0)
    assert result == 42

Gateway wrapper normalization

If your adapter or connector proxies gateway call_tool / call_tools requests, normalize wrapper payloads with the shared helpers instead of reimplementing JSON coercion:

python
from dcc_mcp_core.host import normalize_tool_arguments, normalize_tool_meta

arguments = normalize_tool_arguments(payload.get("arguments"))
meta = normalize_tool_meta(payload.get("meta"))

These helpers mirror the Rust dcc-mcp-wire contract: missing / None / empty-string arguments become {}, object roots pass through, object-shaped JSON strings are accepted, and arrays/numbers/booleans/non-object strings raise a validation error. Keep backend-specific values (code, file_path, radius, …) inside arguments.

Checklist when opening a DCC-integration repo

  • [ ] Subclass HostAdapter, implement the 3 hooks.
  • [ ] Ship at least one example skill (a single tool is enough) that proves bpy.ops / maya.cmds / equivalent works on the main thread.
  • [ ] Add a CI job that starts the DCC headless, runs an mcporter call against the live server, and asserts success.
  • [ ] Write a README.md pointing back at this doc so future maintainers understand the contract.
  • [ ] Open a tracking issue in your repo; cross-reference the core's umbrella issue so progress is visible across repos.

Released under the MIT License.