Skip to content

Agents Reference — Detailed Rules and Traps

This file is the detailed companion to AGENTS.md. AGENTS.md is the navigation map; this file holds the expanded rules, code examples, and traps that agents need on demand. Read AGENTS.md first, then follow links here when you need detail.


Traps — Detailed Reference

These are the most common mistakes. Each takes less than 10 seconds to check.

scan_and_load returns a 2-tuple — always unpack:

python
# ✓
skills, skipped = scan_and_load(dcc_name="maya")
# ✗ iterating gives (list, list), not skill objects

success_result / error_result — kwargs go into context, not a context= kwarg:

python
# ✓
result = success_result("done", prompt="hint", count=5)
# result.context == {"count": 5}

ToolDispatcher — only .dispatch(), never .call():

python
dispatcher = ToolDispatcher(registry)          # one arg only
result = dispatcher.dispatch("name", json_str)   # returns dict

Async tools/call dispatch (#318) — opt-in, non-blocking:

python
# Any of these routes the call through JobManager and returns immediately
# with {job_id, status: "pending"}:
#   1. Request carries _meta.dcc.async = true
#   2. Request carries _meta.progressToken
#   3. Tool's ToolMeta declares execution: async or timeout_hint_secs > 0
# Otherwise dispatch is synchronous (byte-identical to pre-#318 behaviour).
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
    "name": "render_frames",
    "arguments": {"start": 1, "end": 250},
    "_meta": {"dcc": {"async": True, "parentJobId": "<uuid-or-null>"}},
}}
# → result.structuredContent = {"job_id": "<uuid>", "status": "pending",
#                               "parent_job_id": "<uuid>|null"}
# Poll via jobs_get_status (#319); cancelling the parent cancels every child
# whose _meta.dcc.parentJobId matches (CancellationToken child-token cascade).

ToolRegistry.register() — keyword args only, no positional:

python
registry.register(name="my_tool", description="...", dcc="maya")

Tool annotations live in the sibling tools.yaml, never at the SKILL.md top level (#344): Declare MCP ToolAnnotations as a nested annotations: map on each tool entry (or the legacy shorthand flat *_hint: keys). Nested map wins whole-map when both forms are present. deferred_hint is a dcc-mcp-core extension and rides in _meta["dcc.deferred_hint"] on tools/list — never inside the spec annotations map. Full guide: docs/guide/skills.md#declaring-tool-annotations-issue-344.

SKILL.md sibling-file pattern — THE rule for every new extension (v0.15+ / #356):

Do not add new top-level frontmatter keys to SKILL.md. agentskills.io 1.0 only allows name, description, license, compatibility, metadata, allowed-tools at the top level. Every dcc-mcp-core extension — tools, groups, workflows, prompts, behaviour chains, annotations, templates, examples packs, anything future — MUST be expressed as:

  1. A nested namespace under metadata: named dcc-mcp.
  2. Each extension key's value is a glob or filename pointing at a sibling file (YAML or Markdown) that carries the actual payload.
  3. The sibling file lives inside the skill directory, not inline in SKILL.md.
yaml
---
name: maya-animation
description: >-
  Maya animation keyframes, timeline, curves. Use when the user asks to
  set/query keyframes, change timeline range, or bake simulations.
license: MIT
metadata:
  dcc-mcp:
    dcc: maya
    tools: "tools.yaml"              # ✓ points at sibling
    groups: "tools.yaml"             # ✓ same or separate file
    workflows: "workflows/*.workflow.yaml"
    prompts: "prompts/*.prompt.yaml"
    examples: "references/EXAMPLES.md"
---
# body — human-readable instructions only

Use the nested form for all new and migrated skills. The pre-0.15 flat dotted form (metadata: { "dcc-mcp.dcc": ... }) no longer populates typed fields in strict v0.15+ loaders, even though some older examples may still parse as raw metadata.

maya-animation/
├── SKILL.md                    # metadata map + body
├── tools.yaml                  # tools + groups
├── workflows/
│   ├── vendor_intake.workflow.yaml
│   └── nightly_cleanup.workflow.yaml
├── prompts/
│   └── review_scene.prompt.yaml
└── references/
    └── EXAMPLES.md

Why this is non-negotiable:

  • skills-ref validate passes — no custom top-level fields.
  • Progressive disclosure — agents only pay tokens for the sibling files they actually need; a 60-tool skill stays cheap to index.
  • Diffable — one PR per workflow/prompt file, not buried in a monster SKILL.md block.
  • Forward-compatible — future extensions add a new metadata.dcc-mcp.<x> key and a new sibling schema, without re-negotiating the frontmatter spec.

When you design a new feature that touches SKILL.md, the design review gate is: "Can this live as a metadata.dcc-mcp.<feature> pointer to sibling files?" If the answer is no, bring it to a proposal before implementing (see docs/proposals/).

ToolRegistry method names still use "action" (v0.13 compatibility):

python
# The Rust API was renamed action→tool in v0.13, but some method names
# remain as "action" for backward compatibility:
registry.get_action("create_sphere")           # still "get_action"
registry.list_actions(dcc_name="maya")         # still "list_actions"
registry.search_actions(category="geometry")   # still "search_actions"
# These are NOT bugs — they are compatibility aliases.

DccLink IPC — primary RPC path (v0.14+, issue #251):

python
from dcc_mcp_core import DccLinkFrame, IpcChannelAdapter
channel = IpcChannelAdapter.connect("dcc-mcp-maya-12345")  # Named Pipe / UDS
channel.send_frame(DccLinkFrame(msg_type="Call", seq=1, body=b"{...}"))
reply = channel.recv_frame()   # DccLinkFrame: msg_type, seq, body
# Legacy FramedChannel.call / connect_ipc were REMOVED in v0.14 (#251).

Multi-client IPC server:

python
from dcc_mcp_core import SocketServerAdapter
server = SocketServerAdapter("/tmp/maya.sock", max_connections=8,
                             connection_timeout_secs=30)

DeferredExecutor — not in public __init__:

python
from dcc_mcp_core._core import DeferredExecutor   # direct import required

McpHttpServer — register ALL handlers BEFORE .start(). This includes register_diagnostic_mcp_tools(...) for instance-bound diagnostics — register them before calling server.start(), never after.

Return ToolResult from Python tool handlers (#487) — never hand-roll the dict:

python
from dcc_mcp_core.result_envelope import ToolResult

# ✓ typed envelope; serialises to the same wire shape clients already see.
# Factory methods are `success_` / `error_` (trailing underscore avoids
# shadowing the dataclass fields), with shorter aliases `ok` / `fail`.
return ToolResult.ok("Loaded skill", name=name).to_dict()
return ToolResult.fail("Skill missing", error="not_found",
                       prompt="Try `recipes__list`.").to_dict()
# `ToolResult.not_found("Skill", name)` and `ToolResult.invalid_input(msg)`
# are convenience constructors for the two most common error codes.

# ✗ ad-hoc dict — no field validation, drifts when the wire shape evolves
return {"success": True, "message": "...", "context": {"name": name}}

The dataclass mirrors the Rust ToolResult model; empty fields are pruned by .to_dict() so feature-flag toggles do not perturb the JSON envelope.

Trap (#487): there is no ToolResult.success(...) / ToolResult.error(...) classmethod — success and error are dataclass fields, so the factories are spelled success_ / error_ (or the cleaner aliases ok / fail). Calling ToolResult.success("...") raises AttributeError: type object 'ToolResult' has no attribute 'success'.

Import metadata key strings from constants.py (#487):

python
from dcc_mcp_core.constants import (
    METADATA_RECIPES_KEY,    # "dcc-mcp.recipes"
    METADATA_LAYER_KEY,      # "dcc-mcp.layer"
    LAYER_THIN_HARNESS,      # "thin-harness"
    CATEGORY_RECIPES,        # "recipes"
)
# ✗ never inline literals — renaming a key now means editing one file

Every "dcc-mcp.<feature>" metadata key, every metadata.dcc-mcp.layer value, and every category tag on ToolRegistry.register(...) lives in dcc_mcp_core.constants. Adding a new key? Add it to constants.py first, import it everywhere it appears.

Connection-scoped tool cache (issue #438):tools/list caches a per-session snapshot by default (enable_tool_cache=True). The cache is invalidated automatically on skill load/unload and group activation/deactivation. To force a fresh build for a single request, send _meta.dcc.refresh=true on the tools/list call. The cache does not apply to tools/call — only to tools/list response construction.

MCP-facing input schemas stay simple:tools/list should expose a client-compatible object schema: top-level type: object, properties, required, primitive property types, bounds, and short descriptions. Do not rely on JSON Schema composition or conditionals (anyOf, oneOf, allOf, not, if/then/else, dependent schemas) to express tool semantics. Keep those rules in the handler/tool script so clients can register the MCP tool first, then receive normal validation errors at call time.

USD project resources (issue #1209):

python
from dcc_mcp_core import register_usd_project_resources

register_usd_project_resources(
    server,
    project_root="/show/shot010/usd",
    stage="/show/shot010/usd/shot.usda",
    layers=["/show/shot010/usd/lighting.usda"],
    project_label="shot010",
)

Use this helper for headless OpenUSD, Houdini Solaris, Maya USD, Blender USD, Unreal, and Omniverse-style adapters. It registers canonical openusd://stage, openusd://layers, openusd://assets, openusd://materials, openusd://validation, openusd://snapshots, and openusd://packages resources with stable MIME and file-ref metadata. Do not reach into server._server.* to publish USD resources.

Adapter readiness binder (issue #1206):

python
from dcc_mcp_core import AdapterReadinessBinder

readiness = AdapterReadinessBinder.bind_queue_dispatcher(
    server,
    dispatcher,
    dcc_ready_probe=lambda: is_dcc_api_ready(),
    require_first_pump=True,
)

Use the binder before server.start() so one ReadinessProbe gates MCP tools/call, REST /v1/readyz, and REST /v1/call. Prefer bind_inline() / bind_headless() for hosts that execute on the current thread. In adapter tests, assert readiness.report_subset(...) instead of the full readiness dict so future core bits do not break stable contracts.

Capturer.new_auto() vs .new_window_auto():

python
# ✓ full-screen / display capture (DXGI on Windows, X11 on Linux)
Capturer.new_auto().capture()

# ✓ single-window capture (HWND PrintWindow on Windows; Mock elsewhere)
Capturer.new_window_auto().capture_window(window_title="Maya 2024")
# ✗ .new_auto() then .capture_window() — may return an incorrect backend

Tool groups — inactive groups are hidden, not deleted:

python
# default_active=false tools are hidden from tools/list but remain in ToolRegistry.
# Use registry.list_actions() (shows all) vs registry.list_actions_enabled() (active only).
registry.activate_tool_group("maya-geometry", "rigging")   # emits tools/list_changed

skill_success() vs success_result() — different types, different use cases:

python
# Inside a skill script (pure Python, returns dict for subprocess capture):
return skill_success("done", count=5)       # → {"success": True, ...} dict

# Inside server code (returns ToolResult for validation/transport):
return success_result("done", count=5)      # → ToolResult instance

SkillScope — higher scope overrides lower for same-name skills:

python
# Scope hierarchy: Repo < User < Team < System < Admin
# A System-scoped skill silently shadows a Repo-scoped skill with the same name.
# This prevents project-local skills from hijacking enterprise-managed ones.
# SkillScope is exported to Python for introspection. SkillPolicy checks are
# exposed through SkillMetadata: metadata.is_implicit_invocation_allowed(),
# metadata.matches_product(dcc_name). Configure via SKILL.md frontmatter:
#   allow_implicit_invocation: false
#   products: ["maya", "blender"]

allow_implicit_invocation: falsedefer-loading: true:

yaml
# allow_implicit_invocation: false → skill must be explicitly load_skill()'d
# defer-loading: true → tool stub appears in tools/list but needs load_skill()
# Both delay tool availability, but the former is a *policy* (security),
# the latter is a *hint* (progressive loading). Use both for maximum control.

MCP security — design tools for safe AI interaction:

python
# Use ToolAnnotations to signal safety properties to AI clients:
from dcc_mcp_core import ToolAnnotations
annotations = ToolAnnotations(
    read_only_hint=True,       # tool only reads data, no side effects
    destructive_hint=False,    # tool may cause irreversible changes
    idempotent_hint=True,      # repeated calls produce same result
    open_world_hint=False,     # tool may interact with external systems
    deferred_hint=None,        # full schema deferred until load_skill (set by server, not user)
)
# Design tools around user workflows, not raw API calls.
# Return human-readable errors via error_result("msg", "specific error").
# Use notifications/tools/list_changed when the tool set changes.

skill_warning() / skill_exception() — additional skill helpers:

python
from dcc_mcp_core import skill_warning, skill_exception
# skill_warning() — partial success with warnings (success=True but with caveat)
# skill_exception() — wrap an exception into error dict format
# Both are pure-Python helpers in python/dcc_mcp_core/skill.py

next-tools — live inside the sibling tools.yaml, never top-level SKILL.md (issue #342):

yaml
# tools.yaml  (referenced from SKILL.md via metadata.dcc-mcp.tools: tools.yaml)
tools:
  - name: create_sphere
    next-tools:
      on-success: [maya_geometry__bevel_edges]    # suggested after success
      on-failure: [dcc_diagnostics__screenshot]   # debug on failure
  • next-tools is a dcc-mcp-core extension (not in agentskills.io spec)
  • Lives inside each tool entry in tools.yaml. Top-level next-tools: on SKILL.md is rejected by the loader.
  • Surfaces on CallToolResult._meta["dcc.next_tools"] — server attaches on_success after success and on_failure after error; omitted entirely when not declared.
  • Invalid tool names are dropped at load-time with a warn — skill still loads.
  • Both on-success and on-failure accept lists of fully-qualified tool names.

agentskills.io fields — license, compatibility, allowed-tools:

yaml
---
name: my-skill
description: "Does X. Use when user asks to Y."
license: MIT                          # optional — SPDX identifier or file reference
compatibility: "Maya 2024+, Python 3.8+"  # optional — environment requirements
allowed-tools: Bash(git:*) Read       # optional — pre-approved tools (experimental)
---
  • license and compatibility are parsed into SkillMetadata fields
  • allowed-tools is experimental in agentskills.io spec — space-separated tool strings
  • Most skills don't need compatibility; only include it when there are hard requirements

external_deps — declare external requirements (MCP servers, env vars, binaries):

python
import json
from dcc_mcp_core import SkillMetadata
# external_deps is a JSON string field on SkillMetadata
md.external_deps = json.dumps({
    "tools": [
        {"type": "mcp", "value": "github-mcp-server"},
        {"type": "env_var", "value": "GITHUB_TOKEN"},
        {"type": "bin", "value": "ffmpeg"},
    ]
})
# Read it back:
deps = json.loads(md.external_deps) if md.external_deps else None
  • Declared in a sibling file referenced by metadata.dcc-mcp.external-deps / metadata.dcc-mcp.external_deps; do not put external_deps: at top level in SKILL.md.
  • Parsed into SkillMetadata.external_deps as a JSON string
  • Access via json.loads(metadata.external_deps) — returns None if not set
  • See Skill Scopes & Policies for the full schema

runtimes — advertise optional adapter runtime capabilities safely:

yaml
metadata:
  dcc-mcp:
    runtimes:
      - name: usd-core
        type: python_package
        package: usd-core
        module: pxr
        optional: true
        feature_level: full-usd
        install_hint: "pip install dcc-mcp-openusd[usd-core]"
      - name: usdcat
        type: binary
        binary: usdcat
        optional: true
      - name: houdini-solaris
        type: env_var
        env: HFS
        optional: true
  • Use inline metadata.dcc-mcp.runtimes or point it at a sibling runtimes.yaml; do not add top-level runtime keys.
  • Supported descriptor type values are python_package, python_extra, binary, env_var, and feature.
  • Discovery probes are read-only: Python packages use importlib.util.find_spec(), binaries use PATH, env vars check for non-empty values, and no tool script is imported or executed.
  • Optional absent runtimes resolve to degraded; required absent runtimes resolve to missing. Search, list, detail, gateway search, and REST describe expose those states so agents can avoid calling unavailable capabilities.

CompatibilityRouter — not a standalone Python class:

python
# CompatibilityRouter is returned by VersionedRegistry.router()
# It is NOT importable directly — access via:
from dcc_mcp_core import VersionedRegistry
vr = VersionedRegistry()
router = vr.router()  # -> CompatibilityRouter (borrows the registry)
# For most use cases, use VersionedRegistry.resolve() directly instead
result = vr.resolve("create_sphere", "maya", "^1.0.0")

Client-safe MCP tool naming — validate names before registration:

python
from dcc_mcp_core import validate_tool_name, validate_action_id, TOOL_NAME_RE
# Tool names: ASCII letters/digits/_/- only, max 64 chars.
validate_tool_name("scene_get_info")     # ✓ passes
validate_tool_name("scene.get_info")     # ✗ raises ValueError
validate_tool_name("Scene/GetInfo")      # ✗ raises ValueError
# Action IDs: dotted lowercase identifier chains
validate_action_id("maya_geometry.create_sphere")  # ✓
# Regex constants for custom validation:
# TOOL_NAME_RE, ACTION_ID_RE, MAX_TOOL_NAME_LEN (64 chars)

Workflow step policies — retry / timeout / idempotency (#353):

python
from dcc_mcp_core import WorkflowSpec, BackoffKind
spec = WorkflowSpec.from_yaml_str(yaml)
spec.validate()  # idempotency_key template refs checked HERE, not at parse
retry = spec.steps[0].policy.retry
# next_delay_ms is 1-indexed: 1 = initial attempt (returns 0), 2 = first retry
assert retry.next_delay_ms(1) == 0
assert retry.next_delay_ms(2) == retry.initial_delay_ms
# Exponential doubles: attempt n >= 2 → initial * 2^(n-2), clamped to max
  • max_attempts == 1 means no retry (not "retry once")
  • retry_on: None = every error retryable; retry_on: [] = no error retryable
  • idempotency_scope defaults to "workflow" (per-invocation), set "global" for cross-invocation
  • Template roots must be in inputs/steps/item/env, a top-level input key, or a step id — static-checked on validate()

lazy_actions — opt-in meta-tool fast-path:

python
# When enabled, tools/list surfaces only 3 meta-tools:
# list_actions, describe_action, call_action
# instead of every registered tool at once.
config = McpHttpConfig(port=8765)
config.lazy_actions = True   # opt-in; default is False

bare_tool_names — collision-aware bare action names (#307):

python
# Default True. tools/list emits "execute_python" instead of
# "maya_scripting__execute_python" when the bare name is unique.
# Collisions fall back to the full "<skill>__<action>" form.
# tools/call accepts the exact client-safe name from tools/list.
config = McpHttpConfig(port=8765)
config.bare_tool_names = True   # default

# Opt-out if a downstream client needs explicit skill-qualified names:
config.bare_tool_names = False

ToolResult.to_json() — JSON serialization:

python
result = success_result("done", count=5)
json_str = result.to_json()    # JSON string
# Also: result.to_dict()       # Python dict

Lifecycle hooks — policy vs observation events:

python
# Policy events (BEFORE_SKILL_LOAD, BEFORE_TOOL_CALL, BEFORE_SEARCH):
# HookDeny propagates to veto the operation.
@hooks.on(HookEvent.BEFORE_TOOL_CALL)
def policy(ctx):
    raise HookDeny("blocked", hint="use typed skill X instead")

# Observation events (all others):
# HookDeny is LOGGED and SWALLOWED — handlers continue.
@hooks.on(HookEvent.AFTER_TOOL_CALL)
def observe(ctx):
    # Raising HookDeny here is silently logged — no veto.
    pass

Lifecycle hooks — handler registration is by identity (is), not equality:

python
hooks.on(HookEvent.AFTER_TOOL_CALL, my_handler)
# Later removal must pass the EXACT same callable object:
hooks.off(HookEvent.AFTER_TOOL_CALL, my_handler)  # ✅
# A new lambda with the same body is a different object:
hooks.off(HookEvent.AFTER_TOOL_CALL, lambda ctx: print(ctx))  # ❌ never matches

Lifecycle hooks — on() returns the handler for decorator use:

python
@hooks.on(HookEvent.BEFORE_TOOL_CALL)
def my_handler(ctx): ...  # ✅ handler is also registered

Lifecycle hooks — before_* payload mutation is the side-channel:

python
@hooks.on(HookEvent.BEFORE_SEARCH)
def add_tags(ctx):
    ctx.payload.setdefault("tags", []).append("production")
    # Mutated payload is visible to search caller — no return value needed.

Agent memory — MemoryRecorder.install() wires 6 lifecycle events:

python
hooks = LifecycleHooks()
store = InMemoryMemoryStore()
recorder = MemoryRecorder(store)
recorder.install(hooks)  # registers SESSION_START/END, BEFORE/AFTER handlers
# Forgetting to call install() means no memory is recorded.
# Injection is conservative: search gets compact ranking hints, tool calls get
# memory only when it matches the current tool_name, and SESSION_START injection
# is opt-in via inject_on_session_start=True.

Agent memory — raw prompts are NEVER stored:

python
# payload keys containing "prompt", "api_key", "password", "secret", "token"
# are redacted. Always pass structured JSON-safe data, never raw LLM prompts.
entry = MemoryEntry(
    layer=MemoryLayer.WORKING,
    key="tool_call:export:ok",
    session_id="s1",
    dcc_name="maya",
    payload={"tool_name": "export_fbx", "duration_ms": 340}  # ✅ structured
    # payload={"prompt": "export the scene"}                   # ❌ redacted
)

Agent memory — query sorts by recency then score:

python
# Results: most-recent-first, then highest-score-first within same timestamp.
results = store.query(MemoryQuery(layer=MemoryLayer.WORKING, limit=8))
# Order: (newest, score 1.0), (newest, score -1.0), ..., (oldest, score 1.0)

Agent memory — persist only durable patterns unless explicitly configured:

python
from dcc_mcp_core import MemoryRecorder, SqliteMemoryStore

store = SqliteMemoryStore()  # defaults to LONGTERM persistence only
MemoryRecorder(store).install(hooks)
# EPHEMERAL and WORKING remain process-local by default.

Agent memory — observability is explicit and low-cardinality:

python
stats = recorder.stats()
assert 0.0 <= stats["summary_hit_rate"] <= 1.0
# Admin Memory reads durable rows from the gateway admin SQLite DB.

Phase hook signature mismatch — non-fatal, CI-invisible (PIP-2479, PIP-2468):

_registration.py dispatches phase hooks with calling conventions that vary by phase — 3 hooks receive a RegistrationContext argument and 7 receive nothing. If an adapter override has the wrong parameter count, run_registration_phases catches the TypeError as non-fatal (except Exception at line 80 of _registration.py), logs it, and continues — the tools for that phase silently fail to register.

python
# server_base.py — definition (DccServerBase)
def _register_introspect_tools(self) -> None:      # ← no `context` arg
    ...

# _registration.py — call site (IntrospectToolsPhase.run)
server._register_introspect_tools()                 # ← 0 args passed

# Adapting override with wrong signature:
def _register_introspect_tools(self, context): ...  # ← TypeError at runtime!
# → caught as non-fatal by run_registration_phases → phase marked failed
# → tools never registered → CI still GREEN because test uses MockServer

Prevention:

  1. Static: verify that every DccServerBase phase hook method signature matches its caller in _registration.py — see tests/test_phase_hook_signature_consistency.py for the automated check.
  2. Integration: every adapter must have at least one test that runs get_standard_phases() against the real server class (not MockServer) and asserts no TypeError.
  3. Code review: before approving PRs that touch registration or phase hooks, check that the caller signature in _registration.py matches the hook definition in server_base.py (or the adapter subclass).

Do and Don't — Full Reference

Do ✅

  • Use create_skill_server("maya", McpHttpConfig(port=8765)) — the Skills-First entry point since v0.12.12
  • Use success_result("msg", count=5) — extra kwargs become context dict
  • Use ToolAnnotations(read_only_hint=True, destructive_hint=False) — helps AI clients choose safely
  • Use next-tools: on-success/on-failure inside sibling tools.yaml entries — guides AI agents to follow-up tools
  • Use metadata.dcc-mcp.search-hint in SKILL.md — improves search_skills keyword matching
  • Use tool groups with default_active: false for power-user features — keeps tools/list small
  • Tag every skill with metadata.dcc-mcp.layerinfrastructure, domain, or example. See the skill layer taxonomy in AGENTS.md.
  • Start every skill description with the layer prefix (Infrastructure skill — / Domain skill — / Example skill —) followed by a "Not for X — use Y" negative routing sentence
  • Keep search-hint non-overlapping across layers — infrastructure: mechanism-oriented; domain: intent-oriented; example: append "authoring reference"
  • Wire every domain skill tool on-failure to [dcc_diagnostics__screenshot, dcc_diagnostics__audit_log]
  • Declare dependencies via metadata.dcc-mcp.depends / metadata/depends.md in every domain skill that uses on-failure chains
  • For every new SKILL.md extension, use a metadata.dcc-mcp.<feature> key pointing at a sibling file (see "SKILL.md sibling-file pattern" in Traps). Same rule for tools, groups, workflows, prompts, and anything future.
  • Unpack scan_and_load(): skills, skipped = scan_and_load(dcc_name="maya")
  • Register ALL handlers BEFORE McpHttpServer.start() — the server reads the registry at startup
  • Use SandboxPolicy + InputValidator for AI-driven tool execution
  • Use DccServerBase as the base class for DCC adapters — skill/lifecycle/gateway inherited
  • Use vx just dev before vx just test — the Rust extension must be compiled first
  • Keep SKILL.md body under 500 lines / 5000 tokens — move details to references/
  • Use Conventional Commits for PR titles — feat:, fix:, docs:, refactor:
  • Use registry.list_actions() (shows all) vs registry.list_actions_enabled() (active only)
  • Start with search_skills(query) when looking for a tool — don't guess tool names. search_skills accepts tags, dcc, scope, and limit; call it with no arguments to browse by trust scope.
  • Use init_file_logging(FileLoggingConfig(...)) for durable logs in multi-gateway setups; call flush_logs() to force events to disk immediately
  • Rely on client-safe tool names in tools/call; use _ or -, not dotted tool names
  • Use LifecycleHooks for adapter policy and observation — typed events with fail-safe dispatch (#1337)
  • Raise HookDeny(reason, hint=...) from BEFORE_* handlers to veto — only works for policy events (#1337)
  • Use MemoryRecorder(store).install(hooks) for zero-code skill/tool memory — wires 6 lifecycle events automatically (#1334)
  • Use SqliteMemoryStore() only when longterm memory should be durable and visible in the Admin Memory tab (#1334)
  • Pass structured, JSON-safe payloads to MemoryEntry — never raw prompts or sensitive keys (#1334)
  • Use register_all_builtin_skills(server, dcc_name=..., skills=...) — one call registers all standard MCP tools (#1332)

Don't ❌

  • Don't iterate over scan_and_load() result directly — it returns (list, list), not skill objects
  • Don't use success_result("msg", context={"count": 5}) — kwargs go into context automatically
  • Don't call ToolDispatcher.call() — method is .dispatch(name, json_str)
  • Don't pass positional args to ToolRegistry.register() — keyword args only
  • Don't hardcode scope strings or import SkillPolicy from Python — use exported SkillScope for introspection and SkillMetadata methods for policy checks
  • Don't import DeferredExecutor from public __init__ — use from dcc_mcp_core._core import DeferredExecutor
  • Don't call .new_auto() then .capture_window() — use .new_window_auto() for single-window capture
  • Don't use legacy APIs: ActionManager, create_action_manager(), MiddlewareChain, Action — removed in v0.12+
  • Don't put ANY dcc-mcp-core extension at the top level of a SKILL.md (v0.15+ / #356) — the rule is architectural, not a list of specific fields. tools, groups, workflows, prompts, next-tools behaviour chains, examples packs, and any future extension MUST be a metadata.dcc-mcp.<feature> key pointing at a sibling file. See the "SKILL.md sibling-file pattern" trap for the full rationale. The loader rejects any non-spec top-level key outright (not a warning) — a SKILL.md with top-level dcc:/tags:/tools:/groups:/depends:/search-hint: fails to load. See docs/guide/skills.md#migrating-pre-015-skillmd.
  • Don't inline large payloads (workflow specs, prompt templates, example dialogues, annotation tables) into SKILL.md frontmatter or body, even under metadata: — use sibling files. SKILL.md body stays ≤500 lines / ≤5000 tokens.
  • Don't create a skill without metadata.dcc-mcp.layer — untagged skills cause routing ambiguity as the catalog grows
  • Don't write a domain skill description without a "Not for X" sentence — agents need explicit counter-examples to avoid picking the wrong skill
  • Don't overlap search-hint keywords between infrastructure and domain skills — overlapping keywords make search_skills() return ambiguous results
  • Don't use removed transport APIs: FramedChannel, connect_ipc(), IpcListener, TransportManager, CircuitBreaker, ConnectionPool — removed in v0.14 (#251). Use IpcChannelAdapter / DccLinkFrame instead
  • Don't add Python runtime dependencies — the project is zero-dep by design
  • Don't manually bump versions or edit CHANGELOG.md — Release Please handles this
  • Don't hardcode API keys, tokens, or passwords — use environment variables
  • Don't use docs/ prefix in branch names — causes refs/heads/docs/... conflicts
  • Don't hard-code dotted <skill>.<action> tool names in tools/call; skill-qualified names use the client-safe skill__action shape
  • Don't reference ToolMeta.enabled in Python — use ToolRegistry.set_tool_enabled() instead
  • Don't use json.dumps() on ToolResult — use result.to_json() or serialize_result()
  • Don't guess tool names — use search_skills(query) to discover the right tool.
  • Don't add a generic utils / common / helpers crate — every helper has a natural owner (a domain crate, dcc-mcp-paths, dcc-mcp-logging, or dcc-mcp-pybridge). See the Workspace Boundary Rationale section.
  • Don't raise HookDeny from observation events (AFTER_*, SESSION_*) — it is logged and swallowed, providing no veto (#1337)
  • Don't forget MemoryRecorder.install(hooks) — without it, no events are wired and zero memory is recorded (#1334)
  • Don't store raw prompts or credentials in MemoryEntry.payload — keys containing prompt/api_key/password/secret/token are redacted (#1334)

Code Style

Python

  • from __future__ import annotations — first line of every module
  • Import order: future → stdlib → third-party → local (with section comments)
  • Formatter: ruff format (line length 120, double quotes)
  • All public APIs: type annotations + Google-style docstrings

Rust

  • Edition 2024, MSRV 1.85
  • tracing for logging (no println!)
  • thiserror for error types
  • parking_lot instead of std::sync::Mutex

Writing Tool Descriptions — Style Guide

Every built-in MCP tool description (see build_core_tools_inner and build_lazy_action_tools in crates/dcc-mcp-http/src/handler.rs) follows the 3-layer behavioural structure adopted in issue #341: a one-sentence present-tense "what" summary, a When to use: paragraph contrasting the tool against its siblings (so the agent knows when NOT to pick it), and a How to use: bullet list covering preconditions, common pitfalls, and follow-up tools. Keep the whole string ≤ 500 chars (MCP clients truncate long text); if more context is needed, move it to docs/api/http.md and reference the anchor from the description. Per-parameter description fields in the input schema are single clauses ≤ 100 chars. The structural contract is enforced by tests/test_tool_descriptions.py.


MCP Tool Design — Best Practices (Based on MCP Specification)

These practices are derived from the MCP Best Practices guide and the MCP specification. They apply to every tool registered in the dcc-mcp-core ecosystem.

Single Responsibility

One tool = one clear purpose. Avoid "kitchen-sink" tools that try to do everything. A tool called create_sphere is better than a generic create_geometry that takes a type parameter and branches internally.

Why: AI agents select tools by matching descriptions to user intent. Monolithic tools produce ambiguous matches and reduce the agent's ability to chain tools correctly.

Contracts First

Strict input/output schemas, explicit side effects, documented errors.

  • Every tool MUST have an input_schema (JSON Schema) with per-parameter descriptions (≤100 chars each).
  • Every tool handler MUST return ToolResult — never raw dicts.
  • Every error MUST include an actionable prompt suggesting a recovery step.

Safety Annotations

Always set ToolAnnotations so AI clients can make informed choices:

python
from dcc_mcp_core import ToolAnnotations

annotations = ToolAnnotations(
    read_only_hint=True,       # tool only reads data, no side effects
    destructive_hint=False,    # tool may cause irreversible changes
    idempotent_hint=True,      # repeated calls produce same result
    open_world_hint=False,     # tool may interact with external systems
)

Follow-Up Guidance

Use next-tools to guide agents to the logical next step:

  • on-success: What the agent should do after this tool succeeds
  • on-failure: Diagnostic/recovery tools (always point to dcc_diagnostics__*)

This is the primary mechanism for tool chaining — the agent doesn't need to guess what comes next.

Progressive Discovery

Keep tools/list small by default:

  • Use tool groups with default_active: false for power-user features
  • Use search_skills() for discovery — don't enumerate all tools
  • Use SkillCatalog.load_skill() to activate only what's needed

Description Quality

The description field is the most important factor for AI tool selection. It must include:

  1. What the tool does (present tense, one sentence)
  2. When to use it (specific triggers and keywords)
  3. When NOT to use it (negative routing to prevent mismatches)
yaml
# ✓ Good — specific, includes when-to-use and counter-examples
description: >-
  Create a polygon sphere with configurable radius and subdivisions.
  Use when the user asks to create a sphere, ball, or round 3D object.
  Not for creating other primitives — use create_cube or create_cylinder.

# ✗ Bad — vague, no trigger, no counter-examples
description: "Create geometry."

Error Recovery Design

Every tool should provide structured error recovery:

python
from dcc_mcp_core import error_result, ToolResult

# ✓ Good — specific error code + actionable prompt
return ToolResult.fail(
    "Sphere creation failed",
    error="invalid_radius",
    prompt="Radius must be positive. Try create_sphere with radius=1.0.",
).to_dict()

# ✗ Bad — generic error, no guidance
return ToolResult.fail("Error", error="failed").to_dict()

Stateless by Default

Keep tool execution stateless. Externalize state to:

  • CheckpointStore for long-running progress
  • ArtefactStore for cross-tool file handoff
  • FileRegistry for gateway instance metadata

Stateless tools are easier to test, retry, and compose into workflows.


Request-Level Context Passthrough (_meta) — PIP-520

When a client (AI agent, CI pipeline, or external service) calls a tool through the dcc-mcp Gateway, it can attach request-level context via the MCP _meta block. The Gateway forwards this context to the backend adapter skill, where the tool handler can read it from params._meta.

What Gets Passed Through

The Gateway applies a bounded passthrough allowlist — only these fields survive the trip from client to tool handler:

FieldPurposeExample
agent_contextServer-derived caller identity (actor, agent name, session, model){"actor_id":"artist-42","agent_name":"claude-code"}
credential_profileWhich credential/profile to use for backend services"prod", "staging"
permission_hintCaller's permission level for enforcement"read-only", "read-write"
project_scopeProject context for data isolation"movie-42", "game-99"
search_idTelemetry correlation id (set by gateway)"srch-abc123"

Security: The client's self-reported agent_context is stripped by the Gateway. The agent_context the tool sees is always server-derived — populated from HTTP headers, JWT claims, and network attribution. Never trust client-supplied identity fields; always read _meta.agent_context from the server.

How Tool Handlers Consume _meta

The _meta is injected into params as a top-level key after schema validation. This means tools with "additionalProperties": false in their input_schema are safe — the validator never sees _meta.

Rust Handler

rust
dispatcher.register_handler("my_tool", |params: Value| {
    // Read credential_profile from _meta
    let profile = params
        .pointer("/_meta/credential_profile")
        .and_then(Value::as_str)
        .unwrap_or("default");

    // Read server-derived agent_context
    let actor = params
        .pointer("/_meta/agent_context/actor_id")
        .and_then(Value::as_str)
        .unwrap_or("unknown");

    Ok(json!({"resolved": true, "profile": profile, "actor": actor}))
});

Python Handler

python
def my_tool_handler(params: dict) -> dict:
    meta = params.get("_meta", {})

    # Read credential_profile
    profile = meta.get("credential_profile", "default")

    # Read server-derived agent_context
    agent_ctx = meta.get("agent_context", {})
    actor_id = agent_ctx.get("actor_id", "unknown")
    agent_name = agent_ctx.get("agent_name", "unknown-agent")

    # Use for credential resolution, permission checks, scope isolation...
    if profile == "prod":
        endpoint = "https://prod.api.example.com"
    else:
        endpoint = "https://staging.api.example.com"

    return {"resolved": True, "endpoint": endpoint, "actor": actor_id}

Common Patterns

Pattern 1: Credential resolution — select API endpoints/tokens based on credential_profile:

python
profile = params.get("_meta", {}).get("credential_profile", "default")
client = get_client_for_profile(profile)
return client.call(params["service"], **params.get("arguments", {}))

Pattern 2: Permission enforcement — reject destructive actions when permission_hint is "read-only":

python
hint = params.get("_meta", {}).get("permission_hint", "read-write")
if hint == "read-only" and params.get("action") == "delete":
    return error_result("action 'delete' denied: permission_hint is 'read-only'")

Pattern 3: Project-scoped data isolation — filter results by project_scope:

python
scope = params.get("_meta", {}).get("project_scope", "")
results = [a for a in all_assets if not scope or a["project"] == scope]

Pattern 4: Caller attribution — log or route based on agent_context:

python
actor = params.get("_meta", {}).get("agent_context", {}).get("actor_id", "unknown")
logger.info(f"Tool call by {actor}", extra={"actor_id": actor, "tool": action_name})

When to Use Each Field

  • agent_context: telemetry, audit logs, per-user rate limiting, routing.
  • credential_profile: switching between prod/staging/dev backends.
  • permission_hint: read-only enforcement, guard destructive operations.
  • project_scope: multi-project isolation, data filtering, path scoping.

Backward Compatibility

When _meta is absent (no client context sent, or legacy client), the _meta key is simply not injected into params. Tool handlers should always use .get("_meta", {}) with a sensible default. No handler signature changes are required — the _meta key is transparently present or absent.

Gateway-Side Filtering

The Gateway's bounded_meta() filter (in crates/dcc-mcp-gateway/src/gateway/capability_service.rs) strips all client-supplied _meta keys that are not in the allowlist. If you need a new field added to the allowlist, open a PR against DEFAULT_META_ALLOWLIST. The same filter removes any client-supplied agent_context and replaces it with the server-derived one — preventing spoofing.


Adding a New Public Symbol — Checklist

When adding a Rust type/function that needs to be callable from Python:

  1. Implement in crates/dcc-mcp-*/src/
  2. Add #[pyclass] / #[pymethods] bindings in the crate's python.rs
  3. Register in src/lib.rs via the appropriate register_*() function
  4. Re-export through python/dcc_mcp_core/_exports.py by adding the symbol to _LAZY; PUBLIC_EXPORTS and python/dcc_mcp_core/__init__.py derive __all__ from that map
  5. Re-export in the generated stub by running the stub generation/dev build (cargo run --bin stub_gen --features stub-gen or the project build path); _core.pyi is generated output, not hand-edited source
  6. Add tests in tests/test_<module>.py
  7. Run vx just dev to rebuild, then vx just test

Dev Environment Tips

  • Build before testing: Always run vx just dev before vx just test — the Rust extension must be compiled first.
  • Preflight before PR: vx just preflight runs cargo check + clippy + fmt + test-rust — catch issues early.
  • Lint auto-fix: vx just lint-fix auto-fixes both Rust (cargo fmt) and Python (ruff + isort) issues.
  • Version never manual: Release Please owns versioning — never manually edit CHANGELOG.md or version strings.
  • Required checks on every PR: CI intentionally runs for documentation-only changes so repository rulesets always receive stable required statuses.
  • Branch naming: Avoid docs/ prefix (causes refs/heads/docs/... conflicts). Use flat names like feat-xxx or enhance-xxx.

Security Considerations

  • Sandbox: Use SandboxPolicy + SandboxContext for AI-driven tool execution. Never expose unrestricted filesystem or process access.
  • Input validation: Always validate AI-provided parameters with ToolValidator.from_schema_json() before execution.
  • ToolAnnotations: Signal safety properties (read_only_hint, destructive_hint, idempotent_hint, open_world_hint, deferred_hint) so AI clients make informed choices.
  • SkillScope: Trust hierarchy prevents project-local skills from shadowing enterprise-managed ones.
  • Audit log: AuditLog / AuditMiddleware provide traceability for all AI-initiated tool calls.
  • No secrets in code: Never hardcode API keys, tokens, or passwords. Use environment variables or config files outside the repo.

PR Instructions

  • Title format: Use Conventional Commits: feat:, fix:, docs:, refactor:, chore:, test:
  • Scope optional: feat(capture): add DXGI backend
  • Breaking changes: feat!: rename action→tool with footer BREAKING CHANGE: ...
  • Squash merge: PRs are squash-merged — write the final commit message in the PR title.
  • CI must pass: vx just preflight + vx just test + vx just lint must all be green.
  • No version bumps: Release Please handles versioning — never manually bump.

Commit Message Guidelines

  • Use Conventional Commits: feat:, fix:, docs:, refactor:, chore:, test:
  • Scope is optional: feat(capture): add DXGI backend
  • Breaking changes: feat!: rename action→tool with footer BREAKING CHANGE: ...
  • Version bumps are handled by Release Please — never manually edit CHANGELOG.md or version strings

CI & Release

  • PRs must pass: vx just preflight + vx just test + vx just lint
  • Python 3.7 gate: native Linux and Windows wheels plus the full suite on Linux; the lite fallback is validated separately
  • Python 3.8–3.14 gate: a representative PR matrix, plus the full Linux / macOS / Windows matrix on the scheduled workflow
  • Versioning: Release Please (Conventional Commits) — never manually bump
  • PyPI: Trusted Publishing (no tokens) — each of dcc-mcp-core, dcc-mcp-server, and dcc-mcp-core-semantic needs its own PyPI Trusted Publisher; see PyPI Trusted Publishers
  • Every PR emits the stable required statuses, including documentation-only changes
  • Squash merge convention for PRs

Hermetic tests and CI that pass explicit skill roots should set DCC_MCP_DISABLE_DEFAULT_SKILL_PATHS=1. It excludes implicit operator-owned roots (local/platform defaults, marketplace installs, and Admin custom paths) while preserving caller-provided, bundled, and DCC_MCP_*_SKILL_PATHS environment paths. Interactive hosts retain the existing discovery defaults when the variable is unset.


Workspace Boundary Rationale

The Rust workspace deliberately has no utils / common / helpers crate. This is a hard architectural constraint, not a stylistic preference: a previous dcc-mcp-utils crate accreted five unrelated concerns (filesystem helpers, file logging, PyO3 bridges, skill-domain logic, a constants bag) and forced every other crate to transitively pull tracing-appender, tracing-subscriber, time, pyo3, etc. — even pure data crates like dcc-mcp-models and dcc-mcp-naming. The Phase 0 re-cut (issues #485, #496, #497, #498) deleted that crate and redistributed its contents by ownership.

Where each kind of helper lives

Helper kindCrateNotes
Platform directories (get_config_dir, get_data_dir, get_cache_dir, get_log_dir)dcc-mcp-pathsDeps limited to dirs + std — zero PyO3 / tracing
ensure_directory, path_to_stringdcc-mcp-pathsGeneric FS plumbing only
File logging (init_file_logging, FileLoggingConfig, RotationPolicy, rolling writer)dcc-mcp-loggingDepends on tracing-subscriber + tracing-appender; NEVER imported by base data crates
Tracing-subscriber bootstrap (init_logging)dcc-mcp-loggingSame
LOG_* env vars and defaultsdcc-mcp-logging::constantsCo-located with the consumer
PyO3 ↔ JSON bridges (json_value_to_pyobject, py_any_to_json_value, py_dict_to_json_map)dcc-mcp-pybridgeFeature-gated python-bindings; pulled only by crates that actually expose Python
PyO3 ↔ YAML bridges (yaml_dumps, yaml_loads)dcc-mcp-pybridgeSame
BooleanWrapper, FloatWrapper, unwrap_to_json_valuedcc-mcp-pybridgePure PyO3 surface — zero Rust call sites
Skill paths (get_skill_paths_from_env, get_user_skills_dir, get_team_skills_dir, copy_skill_to_user_dir)dcc-mcp-skills::pathsOwned by the only consumer
Skill versioning (archive_skill_version, update_version_manifest)dcc-mcp-skills::versioningDomain logic
Skill feedback (record_skill_feedback, FeedbackEntry)dcc-mcp-skills::feedbackDomain logic
Skill evolution (archive_evolved_skill, save_evolved_skill_version)dcc-mcp-skills::evolutionDomain logic
SKILL_* / ENV_*_SKILL_* constants, SUPPORTED_SCRIPT_EXTENSIONS, is_supported_extension, MTIME_EPSILON_SECSdcc-mcp-skills::constantsCo-located with consumer
DEFAULT_DCC, DEFAULT_VERSIONdcc-mcp-namingCo-located with consumer
DEFAULT_MIME_TYPEdcc-mcp-protocolsCo-located with consumer
DEFAULT_ERROR_TYPE, DEFAULT_ERROR_PROMPT, DEFAULT_SUCCESS_MESSAGE, CTX_KEY_*, ACTION_RESULT_KNOWN_KEYS, default_schema()dcc-mcp-modelsCo-located with consumer
APP_NAME, APP_AUTHORdcc-mcp-paths::constantsUsed to derive platform dirs

Decision rule for new helpers

When you reach for a "tiny shared helper" ask in this order:

  1. Does an existing domain crate consume it? Put it there. A helper used only by dcc-mcp-skills belongs in dcc-mcp-skills, even if it is "generic-looking".
  2. Is it a platform-dir or pathbuf helper used by ≥2 unrelated crates? Put it in dcc-mcp-paths.
  3. Is it a logging concern? Put it in dcc-mcp-logging.
  4. Is it PyO3 conversion plumbing? Put it in dcc-mcp-pybridge under feature = "python-bindings".
  5. None of the above? Inline it at the call site. Do not create a new utility module just to share three lines of code, and never resurrect a utils / common crate.

Compile-time invariants

  • cargo tree -p dcc-mcp-models --no-default-features MUST NOT list tracing-appender, tracing-subscriber, or pyo3.
  • cargo tree -p dcc-mcp-naming and cargo tree -p dcc-mcp-protocols MUST stay at the same dep-count baseline as dcc-mcp-models.
  • The top-level dcc-mcp-core crate is the only place that re-exports PyO3 symbols across crate boundaries; every other crate uses the python-bindings feature gate locally.

Project-Specific Architecture & Constraints

This section collects the runtime invariants and config-knob details that agents must respect when modifying core subsystems. They are derived from shipped issue resolutions and MUST NOT regress.

Skills Pipeline (end-to-end flow)

DCC_MCP_SKILL_PATHS env var

  SkillScanner.scan()           # discovers directories with SKILL.md

  parse_skill_md(dir)           # parses YAML frontmatter + enumerates scripts/

  resolve_dependencies(skills)  # topological sort by 'depends' field

  SkillCatalog.load_skill(name) # on-demand: registers actions into ToolRegistry

  ToolDefinition(...)           # expose as MCP tool to LLM

Action naming: {skill_name}__{script_stem} (hyphens → underscores, __ separator).

tools/list returns three tiers:

  1. Core tools (always): list_skills, get_skill_info, load_skill, unload_skill, search_skills
  2. Loaded skill tools — full input_schema from ToolRegistry
  3. Unloaded skill stubs__skill__<name> with one-line description only

Workflow: search_skills(query="keyword")load_skill("skill-name") → use tools. Calling a stub returns a load_skill hint, not a missing-handler error.

Adapter-owned load policy belongs on the catalog, not around individual entry points. Use DccServerBase.set_skill_load_transform(fn) (or McpHttpServer.set_skill_load_transform(fn) for direct server wiring) when a host needs to mutate SkillMetadata before registration. The same transform runs for direct Python load_skill, MCP tools/call load_skill, REST POST /v1/load_skill, multi-skill loads, and group activation options. Return a mutated SkillMetadata or mutate in place and return None; raise an exception to veto before tools are registered. Use set_after_load_skill_hook(fn) only to observe (skill, registered_actions) after a successful load.

Bundled Skills

Core skills ship inside the wheel under dcc_mcp_core/skills/: app-ui, dcc-diagnostics, workflow.

python
from dcc_mcp_core import get_bundled_skills_dir, get_bundled_skill_paths
paths = get_bundled_skill_paths()       # [".../dcc_mcp_core/skills"]
paths = get_bundled_skill_paths(False)  # [] — opt-out

DCC adapters include these by default (include_bundled=True).

DCC Integration Architectures

DCC adapters integrate through three patterns:

  • Embedded Python (DccServerBase) — Maya, Blender, Houdini, Unreal
  • WebSocket Bridge (DccBridge) — Photoshop, ZBrush, Unity, After Effects
  • WebView Host (WebViewAdapter) — AuroraView, Electron panels

Lifecycle: quit hooks (issue #747)

Embedded adapters can register cleanup callbacks with DccServerBase.register_quit_hook(callback). Hooks run in LIFO order and are best-effort: exceptions are logged and do not block later hooks or core shutdown.

python
server.register_quit_hook(remove_menu)
server.register_quit_hook(flush_scene_snapshot)
with server as handle:
    ...
# quit hooks run, then the MCP server shuts down

The same hook path is used by explicit server.stop(), context-manager exit, and the weak atexit fallback installed by server.start().

Lifecycle Hooks (typed observer/pub-sub, #1337)

LifecycleHooks is a typed, fail-safe registry of event handlers that DccServerBase dispatches at eight hook points. Handlers fire in registration order; exceptions are caught and logged — the host execution never aborts.

Architecture:

Adapter / Policy Code
    |  register handlers via LifecycleHooks.on()
    v
LifecycleHooks          (user-facing registry + dispatch engine)
    ^  dispatch() called by
    |
LifecycleEventDispatcher (internal helper, owned by DccServerBase)
    ^
    |
DccServerBase           (calls dispatch at event boundaries)

Invariants:

  1. Policy events propagate denial. BEFORE_SKILL_LOAD, BEFORE_TOOL_CALL, BEFORE_SEARCH — raising HookDeny from any handler immediately aborts the operation. Non-HookDeny exceptions are logged at WARNING and treated as "no decision" (remaining handlers still run).
  2. Observation events swallow everything. All other events (AFTER_*, SESSION_*) — HookDeny is logged at WARNING and swallowed. All other exceptions are logged with exc_info=True and swallowed. Remaining handlers always execute.
  3. Before-event payload is mutable. Handlers for BEFORE_SEARCH, BEFORE_SKILL_LOAD, BEFORE_TOOL_CALL receive a mutable ctx.payload dict. Mutations in-place are the side-channel — no return protocol.
  4. Registration order is dispatch order. LifecycleHooks.on() appends; LifecycleHooks.off() removes by identity (is), not equality.
  5. No-registry is no-op. All dispatch paths handle hooks_getter returning None gracefully — the hooks system is entirely optional.

Integration with DccServerBase:DccServerBase.register_lifecycle_hooks(hooks) automatically bridges BEFORE_SKILL_LOAD and AFTER_SKILL_LOAD to the inner skill server's set_skill_load_transform / set_after_load_skill_hook. The search_skills() method dispatches BEFORE_SEARCH and AFTER_SEARCH automatically.

Consumers: agent_memory.py (MemoryRecorder), escape_hatch_policy.py, and any custom adapter policy code.

Agent Memory (three-tier, #1334)

agent_memory.py implements a three-tier memory model (EPHEMERAL → WORKING → LONGTERM) wired through LifecycleHooks. It records bounded, structured facts and injects safe memory summaries into search/tool-call context.

Tiers:

TierScopePersistenceRetentionTypical Facts
EPHEMERALPer sessionNeverRing-buffer (256/session)Active scene, loaded skills
WORKINGPer sessionNeverTTL (6h) + ring-buffer (1024/session)Multi-step decisions, tool outcomes
LONGTERMGlobalOpt-in backendGlobal cap (4096 total)Frequently-used skills, conventions

Invariants:

  1. Raw prompts are never stored. The _safe_payload filter redacts keys containing prompt, api_key, authorization, password, secret, token. String values are truncated to 512 chars.
  2. Compaction happens on SESSION_END. When promote_on_session_end=True, WORKING entries are grouped by key, aggregated into pattern:<key> LONGTERM entries with score = ok_count - fail_count, then session-scoped entries are purged.
  3. MemoryStore is a pluggable Protocol. InMemoryMemoryStore is the default; adapters can implement MemoryStore with SQLite, Redis, or file backends.
  4. MemoryRecorder.install() wires 6 events. Call it once with a LifecycleHooks instance; it registers handlers for SESSION_START, BEFORE_SEARCH, AFTER_SKILL_LOAD, BEFORE_TOOL_CALL, AFTER_TOOL_CALL, and SESSION_END.
  5. Injection is conservative and budgeted. BEFORE_SEARCH injects compact ranking hints, BEFORE_TOOL_CALL injects only memory matching the current tool_name, and SESSION_START injection is opt-in. max_summary_chars caps each injected summary.
  6. Summarization is bounded. MemoryRecorder.summarize() queries with limit * 4, classifies into success/failure/escape-hatch/missing buckets, and returns at most summary_limit items per bucket.

Wiring:

python
from dcc_mcp_core import LifecycleHooks, InMemoryMemoryStore, MemoryRecorder

hooks = LifecycleHooks()
store = InMemoryMemoryStore()
MemoryRecorder(store).install(hooks)
server.register_lifecycle_hooks(hooks)

For the lower-level PyO3 handle, prefer deterministic cleanup:

python
with server.start() as handle:
    ...
# handle.shutdown() is called by __exit__

McpHttpConfig(shutdown_on_drop=True) is available as a loud, opt-in safety net for tests and one-shot scripts that accidentally drop the final McpServerHandle reference without calling shutdown().

Script materialization store (issue #1220)

Ad-hoc script execution should cross the DCC boundary as a host-local file path, not as a large inline JSON string. Use dcc_mcp_core.materialize_script(...) when an adapter or agent must create a temporary executable script:

python
from dcc_mcp_core import materialize_script

script = materialize_script(
    "print('hello from host')",
    dcc_type="maya",
    instance_id="maya-2026-abcd",
    session_id="mcp-session-1",
    ttl_secs=3600,
    tool_call_id="call-42",
    correlation_id="trace-abc",
    reuse=True,
)
execute_python(file_path=script.file_path)

The descriptor includes file_ref, absolute file_path, sha256, byte length, language/suffix, TTL/expiry, DCC type, instance id, session id, tool-call id, correlation id, and reuse status. The default root is ~/.dcc-mcp/<dcc_type>/temp/<instance_id>/<session_id>/...; override it with DCC_MCP_SCRIPT_MATERIALIZATION_ROOT when a studio needs a shared host-visible volume. Rust callers use dcc_mcp_artefact::ScriptMaterializationStore.

write_temp_script() remains compatible and now delegates to the same store using a generic local instance/session. New adapter APIs should prefer the structured descriptor so audit, replay, sandbox allowlists, and cleanup can use the same metadata.

DccServerBase adapters expose the same workflow as an agent-facing materialize_script MCP tool, discoverable through search_tools and callable through MCP tools/call or REST /v1/call. The tool accepts content (or legacy code), language, suffix, display_name, reuse, reuse_key, ttl_secs, session_id, tool_call_id, and correlation_id, and returns the descriptor without echoing raw source. Gateway trace input capture redacts script-source fields (code, content, script, python, mel) by default; audit/admin consumers should rely on file_ref, file_path, sha256, bytes, ttl_secs, and reused metadata.

File-backed script execution policy (issue #1221)

Use normalize_file_backed_script_execution_params(...) at adapter and host-bridge boundaries that accept ad-hoc Python or MEL snippets. It accepts file_path / script_path when the path is under the materialization root or an explicit trusted root, and it applies:

  • script_materialization_policy="auto" — inline code is materialized, then execution proceeds with a host-local file_path.
  • script_materialization_policy="require" — raw inline code is rejected; callers must pass a trusted file path or materialize first.
  • script_materialization_policy="off" — legacy inline execution is allowed for adapters that have not migrated yet.

HostExecutionBridge.prepare_script_execution_params(...) and DccApiExecutor.execute_params(...) both use the shared normalizer, so MCP tools such as dcc_execute and in-process adapter execution keep the same contract. When McpHttpConfig.sandbox_policy is attached to DccServerBase, the script materialization root is added to the sandbox path allowlist automatically. Return context.materialized_script metadata from successful executions; it should contain path / file_path, file_ref, sha256, bytes, reused, TTL/session/tool/correlation fields when known. Legacy context keys such as adapter-local spilled script paths may remain during migration, but new code should treat them as deprecated aliases.

MCP HTTP Server Spawn Modes (issue #303)

McpHttpConfig.spawn_mode picks how listeners are driven:

  • Ambient — listeners run as tokio::spawn tasks on the caller's runtime. Correct for #[tokio::main] binaries like dcc-mcp-server where a driver thread persists for the process lifetime.
  • Dedicated — each listener runs on its own OS thread with a current_thread Tokio runtime. Default for PyO3-embedded hosts (Maya/Blender/Houdini). Prevents the "is_gateway=true but port unreachable" failure mode observed on Windows mayapy.

The Python McpHttpConfig defaults spawn_mode = "dedicated"; McpHttpServer.start() self-probes the new listener and refuses to return a handle that claims to be bound when it actually is not. If you write new code that constructs McpHttpServer from Rust inside a PyO3 binding, set spawn_mode = ServerSpawnMode::Dedicated explicitly.

Gateway Lifecycle Invariants (issue #303)

These hold after v0.14 and MUST NOT regress:

  1. handle.is_gateway == True ⇒ the gateway port is reachable. The election code runs a loopback TcpStream::connect self-probe before declaring victory; if the probe fails it falls back to plain-instance mode and returns is_gateway = false. Do not skip this probe.
  2. The gateway supervisor JoinHandle must outlive GatewayHandle. Earlier versions dropped the JoinHandle at the end of start_gateway_tasks; under PyO3-embedded hosts that detached the accept loop and made it unreachable. Keep the JoinHandle in the GatewayHandle struct.
  3. Socket setup errors must not be silenced with .ok()?.try_bind_port returns io::Result; only AddrInUse is treated as a lost election, all other errors are logged at warn level.
  4. Python / PyO3 callers default to ServerSpawnMode::Dedicated.PyMcpHttpConfig::new sets this automatically; py_create_skill_server also coerces AmbientDedicated. Do not revert to Ambient inside Python bindings.

Gateway Reliability + Security Defaults (issues #551–#558)

After the v0.14.18 reliability batch, four invariants protect the gateway from stale or hostile FileRegistry state:

  1. Heartbeat writes are atomic. FileRegistry::heartbeat serialises to a sibling tempfile and uses tempfile::NamedTempFile::persist (atomic rename on POSIX, MoveFileExW on Windows). Concurrent processes can never produce a half-written entry. On Windows, an advisory LockFileEx/UnlockFileEx cycle around persist prevents two writers from racing the rename. Do not bypass the helper — direct fs::write would re-introduce the stomp window.
  2. Dead instances are evicted by active probe, not just by TTL. The gateway runtime spawns a TCP probe loop (tasks.rs::health_check_handle) that connects to each backend's listener every health_check_interval (default 10 s); after health_check_max_failures consecutive misses (default 3) the entry is deregister-ed. The same probe runs once at startup so an entry left behind by a crashed process disappears within the first cycle.
  3. allow_unknown_tools defaults to false. The tools/list aggregator drops any backend whose dcc_type is not in the gateway-side known-DCC registry. This blocks a hijacked or typo'd FileRegistry entry from injecting tools the user never asked for. Tests/local development that need to surface a brand-new DCC must flip McpHttpConfig.allow_unknown_tools = true explicitly.
  4. File logging has sane defaults. New deployments should use default_file_logging_config() instead of hand-rolling a FileLoggingConfig — it picks the platform log directory and a daily rotation policy. Pair it with prune_old_logs(retention_days, max_total_size_mb) (call from a tokio::spawn ticker or at process startup) to enforce both age- and size-based retention so long-lived gateways don't fill the disk.

Gateway Prometheus Metrics (issue #559)

/metrics is off by default. To turn it on, build any consumer of dcc-mcp-http with the prometheus feature (cargo add dcc-mcp-http --features prometheus). With the feature on:

  • gateway::tasks::start_gateway_runtime calls super::metrics::attach_gateway_metrics_route(router) to mount GET /metrics on the same axum Router<()> that serves MCP traffic — the helper takes an Arc<PrometheusExporter> closure so it does not change the router's S (state) type, which keeps it compatible with the rest of the gateway stack.
  • A 5 s background task refreshes dcc_mcp_instances_total{status="active"|"stale"} from a FileRegistry snapshot. Other gauges (dcc_mcp_tools_total, dcc_mcp_request_duration_seconds, dcc_mcp_requests_failed_total) live on dcc_mcp_telemetry::PrometheusExporter and are intended for middleware to update on every request.

When you add new gauges, put the metric definition in crates/dcc-mcp-telemetry/src/prometheus.rs (so non-gateway consumers can reuse it) and the wiring in crates/dcc-mcp-gateway/src/gateway/metrics.rs (so it stays behind the prometheus cfg gate).

Gateway Async-Dispatch + Wait-For-Terminal (issue #321)

The gateway now uses three per-request timeouts instead of one:

  • Sync call (no _meta.dcc.async, no progressToken): governed by McpHttpConfig.backend_timeout_ms (default 120 s, #314).
  • Async opt-in (_meta.dcc.async=true or _meta.progressToken present): governed by McpHttpConfig.gateway_async_dispatch_timeout_ms (default 60 s). Only the queuing step spends this budget — the backend replies with {status:"pending", job_id:"…"} once the job is enqueued.
  • Wait-for-terminal (_meta.dcc.wait_for_terminal=true and an async opt-in): the gateway blocks the tools/call response until $/dcc.jobUpdated reports a terminal status (completed / failed / cancelled / interrupted). Governed by McpHttpConfig.gateway_wait_terminal_timeout_ms (default 10 min). On timeout, the response is the last-known envelope annotated with _meta.dcc.timed_out = true; the job keeps running on the backend.
python
from dcc_mcp_core import McpHttpConfig
cfg = McpHttpConfig(
    port=8765,
    gateway_async_dispatch_timeout_ms=60_000,   # queuing budget
    gateway_wait_terminal_timeout_ms=600_000,   # wait-for-terminal budget
)

Wire-level contract:

jsonc
// POST /mcp — client request
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
  "name":"maya__bake_simulation","arguments":{...},
  "_meta":{"dcc":{"async":true,"wait_for_terminal":true}}
}}
// Gateway blocks the response until $/dcc.jobUpdated status=terminal;
// wait_for_terminal is STRIPPED before forwarding to the backend so
// the backend contract remains unchanged.

Implementation notes for maintainers:

  • Detection helpers live in crates/dcc-mcp-gateway/src/gateway/aggregator.rs (meta_signals_async_dispatch, meta_wants_wait_for_terminal, strip_gateway_meta_flags).
  • The per-job broadcast bus is owned by SubscriberManager (job_event_buses, job_event_channel, publish_job_event, forget_job_bus). The bus is created before the outbound tools/call so terminal events arriving in the tiny window between the backend reply and the waiter installing its subscription are not lost.
  • Backend disconnect during a wait surfaces as -32000 backend disconnected and the job stays in whatever state on the backend (may later become interrupted per #328).

Workflow Execution Pipeline (issue #348)

dcc-mcp-workflow ships the full execution engine. Pipeline sketch:

WorkflowExecutor::run(spec, inputs, parent_job)
   → validate spec
   → create root job + CancellationToken
   → spawn tokio driver
      → drive(steps) sequentially
         → per step: retry + timeout + idempotency_key short-circuit
            → dispatch by StepKind:
               ├─ Tool        → ToolCaller::call
               ├─ ToolRemote  → RemoteCaller::call (via gateway)
               ├─ Foreach     → JSONPath items → drive(body) per item
               ├─ Parallel    → tokio::join! branches (on_any_fail)
               ├─ Approve     → ApprovalGate::wait_handle + timeout
               └─ Branch      → JSONPath cond → then | else
            → artefact handoff (FileRef → ArtefactStore)
            → emit $/dcc.workflowUpdated (enter / exit)
            → sqlite upsert (if job-persist-sqlite)
      → emit workflow_terminal
   → return WorkflowRunHandle { workflow_id, root_job_id, cancel_token, join }

Use WorkflowHost as the stable entry point — it wraps WorkflowExecutor with a run registry keyed by workflow_id, so the three mutating MCP tools (workflows_run / workflows_get_status / workflows_cancel) can be wired with register_workflow_handlers(&dispatcher, &host) after register_builtin_workflow_tools(&registry) has been called.

Key invariants:

  1. Every transition emits $/dcc.workflowUpdated. If you add a new state, route it through RunState::emit.
  2. Cancellation cascades through tokio_util::sync::CancellationToken. Never spawn a step future that drops the token — always pass it into every ToolCaller::call / RemoteCaller::call / tokio::select!.
  3. Idempotency short-circuit happens before retry attempts. A cache hit skips the step entirely; retries only guard live calls.
  4. SQLite recovery flips non-terminal rows to interrupted — never auto-resumes. Resume is explicit opt-in via a separate tool.
  5. Approve gates block on notifications/$/dcc.approveResponse. The HTTP handler for that notification calls ApprovalGate::resolve(workflow_id, step_id, response).

Artefact Hand-Off (issue #349)

python
from dcc_mcp_core import (
    FileRef,
    artefact_put_file, artefact_put_bytes,
    artefact_get_bytes, artefact_list,
)

# Content-addressed SHA-256 store. Duplicate bytes → same URI.
ref = artefact_put_bytes(b"hello", mime="text/plain")
ref.uri          # "artefact://sha256/<hex>"
ref.size_bytes   # 5
ref.digest       # "sha256:<hex>"
assert artefact_get_bytes(ref.uri) == b"hello"

# When McpHttpConfig.enable_artefact_resources=True the server exposes
# every FileRef as an MCP resource — clients resources/read the uri.

Rust side: dcc_mcp_artefact::{FilesystemArtefactStore, InMemoryArtefactStore, ArtefactStore, ArtefactBody, ArtefactFilter, put_bytes, put_file, resolve}. FilesystemArtefactStore persists at <root>/<sha256>.bin + .json.

Resources Primitive (issue #350)

McpHttpConfig.enable_resources defaults to True. Built-in URIs:

  • scene://current — JSON; update via server.resources().set_scene(...) in Rust.
  • capture://current_window — PNG blob; Windows HWND PrintWindow backend only.
  • audit://recent?limit=N — JSON; wire via server.resources().wire_audit_log(log) in Rust.
  • artefact://sha256/<hex> — content-addressed artefact (#349); toggle via enable_artefact_resources.
python
cfg = McpHttpConfig(port=8765)
cfg.enable_resources = True            # advertise capability + built-ins
cfg.enable_artefact_resources = False  # default: artefact:// returns JSON-RPC -32002

Python adapters built on DccServerBase should publish host-owned resources through the public base-class surface: server.register_resource_producer(...), server.set_scene_resource(...), server.notify_resource_updated(...), or server.resources() when the raw ResourceHandle is needed. Do not reach into server._server.*.

Prompts Primitive (issues #351, #355)

McpHttpConfig.enable_prompts defaults to True. Prompts come from each loaded skill's sibling file referenced by metadata["dcc-mcp.prompts"] — either a single prompts.yaml (top-level prompts: + workflows: lists) or a prompts/*.prompt.yaml glob. Workflows referenced by the spec auto-generate a summary prompt.

Template engine is minimal: only {{arg_name}} substitution; missing required args return JSON-RPC INVALID_PARAMS. notifications/prompts/list_changed fires on skill load / unload.

Job Lifecycle Notifications (issue #326)

Every tools/call emits SSE frames:

  • notifications/progress — when _meta.progressToken is set.
  • notifications/$/dcc.jobUpdated — gated by enable_job_notifications (default True).
  • notifications/$/dcc.workflowUpdated — same gate; #348 executor populates it.
python
cfg = McpHttpConfig(port=8765)
cfg.enable_job_notifications = False  # opt the $/dcc.* channels out

Polling fallback: jobs_get_status (#319, always registered) returns the full job-state envelope for a given job_id. Use jobs_cleanup (#328) with older_than_hours to prune terminal jobs; combine with McpHttpConfig.job_storage_path + Cargo feature job-persist-sqlite for restart-safe job history (pending/running rows become Interrupted on reboot).

Scheduler (issue #352)

Opt in with Cargo feature scheduler.

python
from dcc_mcp_core import (
    ScheduleSpec, TriggerSpec, parse_schedules_yaml,
    hmac_sha256_hex, verify_hub_signature_256,
)
cfg = McpHttpConfig(port=8765)
cfg.enable_scheduler = True
cfg.schedules_dir = "/opt/dcc-mcp/schedules"   # loads *.schedules.yaml

ScheduleSpec / TriggerSpec are declarative; the SchedulerService runtime is driven from Rust. Schedules live in sibling schedules.yaml files (never embedded in SKILL.md frontmatter — follow the #356 sibling-file pattern). Cron format is 6-field: "sec min hour day month weekday". Webhook HMAC-SHA256 via X-Hub-Signature-256; secret read from secret_env at startup. On terminal workflow status, host calls SchedulerHandle::mark_terminal(schedule_id) to release max_concurrent.

Prometheus /metrics Exporter (issue #331)

Opt-in behind the prometheus Cargo feature — off by default. When compiled in, enable at runtime via McpHttpConfig(enable_prometheus=True, prometheus_basic_auth=(u, p)). Metric names live in docs/api/observability.md; see there for Grafana PromQL examples. Counters advance from the tools/call wrapper in handler.rs — do not add recording sites elsewhere.


Rust Extension Points (post-EPIC #495)

Five trait-shaped extension points landed during the EPIC #495 architecture audit. Each follows the same recipe: "add a behaviour without editing the upstream match table." All are Rust-only; they live below the PyO3 layer.

MethodHandler + MethodRouter — custom JSON-RPC methods (#492)

Crate: dcc-mcp-http, module handler::router.

rust
use std::sync::Arc;
use dcc_mcp_http::handler::{MethodRouter, MethodHandler, HandlerFuture};
use dcc_mcp_http::handler::state::AppState;
use dcc_mcp_jsonrpc::{JsonRpcRequest, JsonRpcResponse};
use dcc_mcp_http::error::HttpError;

struct PingHandler;
impl MethodHandler for PingHandler {
    fn handle<'a>(
        &'a self,
        _state: &'a AppState,
        req: &'a JsonRpcRequest,
        _session: Option<&'a str>,
    ) -> HandlerFuture<'a> {
        Box::pin(async move {
            Ok(JsonRpcResponse::success(req.id.clone(), serde_json::json!("pong")))
        })
    }
}

let router = MethodRouter::with_builtins();   // initializes, prompts, ...
router.register("ping", Arc::new(PingHandler));
// hand `router` to `AppState::with_method_router(...)`

Capability gating (enable_resources, enable_prompts) lives in the handler itself — return HttpError::method_not_found(...) when a feature is off, never add another arm to the dispatcher. Closures that match the Fn(&AppState, &JsonRpcRequest, Option<&str>) -> HandlerFuture shape implement MethodHandler automatically; reach for a struct only when you need state.

Registry<V> + RegistryEntry — registry-shaped containers (#489)

Crate: dcc-mcp-models, module registry.

ToolRegistry, SkillCatalog, and WorkflowCatalog all impl Registry<V> over their existing storage (per-DCC DashMap, file-hash DashMap, ordered RwLock<Vec>). New registries that need only the contract — not specialised indexes — can use DefaultRegistry<V> directly.

The shared contract test lives in dcc_mcp_models::registry::testing::assert_registry_contract behind the testing feature flag; every implementor calls it once with a fixture so register / get / list / remove / count / search semantics stay in lockstep.

ValidationStrategy + select_strategy — pluggable action validation (#493)

Crate: dcc-mcp-actions, module validation_strategy.

Built-ins: NoOpValidator (no metadata / empty schema) and SchemaValidator<'_> (borrowed-meta JSON Schema check). ToolDispatcher::dispatch calls select_strategy(meta, skip_empty_schema_validation) to pick one per call; adding a new flavour (cached compiled schemas, sandbox precheck, contract-test mode) means a new impl ValidationStrategy and one extra arm in select_strategydispatch() is unaffected. The trait returns ValidationOutcome { skipped: bool } so the dispatcher can record metrics without re-deriving "did this actually run?".

VersionMatcher — pluggable version-constraint shapes (#493)

Crate: dcc-mcp-actions, module versioned::matcher.

Built-in matchers (one per VersionConstraint variant): AnyMatcher, ExactMatcher, AtLeastMatcher, GreaterThanMatcher, AtMostMatcher, LessThanMatcher, CaretMatcher, TildeMatcher. Both VersionConstraint::matches(version) and Display::fmt route through VersionConstraint::with_matcher(...), so adding a new constraint shape takes exactly three edits, none of them in caller code:

  1. one new VersionConstraint enum variant in versioned/mod.rs,
  2. a new matcher struct + impl VersionMatcher in versioned/matcher.rs,
  3. one extra arm in with_matcher.

matches() and Display::fmt need no edits at all.

NotificationBuilder + JsonRpcRequestBuilder — JSON-RPC envelope construction (#484)

Crate: dcc-mcp-jsonrpc, module notification_builder.

Six call sites previously hand-rolled json!({"jsonrpc":"2.0","method":..,"params":..}). The builders are now the single source of truth for that wire shape:

rust
use dcc_mcp_jsonrpc::NotificationBuilder;

let sse_frame = NotificationBuilder::new("notifications/tools/list_changed")
    .with_params(serde_json::json!({}))
    .as_sse_event();   // ready to push onto the per-session stream

.build() returns a typed JsonRpcNotification; .to_value() returns the raw serde_json::Value. JsonRpcRequestBuilder is the symmetric helper for requests (gateway backend client) — it owns the id field.

DccName — typed DCC identifier (#491)

Crate: dcc-mcp-models.

DccName::parse("Maya")DccName::Maya; case-insensitive aliases ("3dsmax", "max", "threedsmax" all map to ThreedsMax). Round-trips through serde_json::to_value(...)serde_json::from_value(...) losslessly via the #[serde(from = "String", into = "String")] annotation. Unknown values become DccName::Other(String) so the enum can grow without breaking external callers. Aliases live in DccName::parse(...) itself: "3dsmax", "max", and "threedsmax" all map to DccName::ThreedsMax; "c4d" and "cinema4d" to DccName::Cinema4d; "photoshop" and "ps" to DccName::Photoshop. Use the type at every new Rust API boundary that previously would have taken &str; existing call sites such as ToolRegistry::list_actions_for_dcc(&str) remain &str for backward compat and can be migrated lazily.

DccMcpError — unified workspace error (#488)

Crate: dcc-mcp-models.

A single error enum with From<HttpError>, From<ProcessError>, … impls. Crates keep their domain-specific enums (HttpError, ProcessError, …) and convert to DccMcpError at the public boundary. New top-level helpers should return Result<T, DccMcpError> rather than introducing yet another error type.


Python 3.7 Support Policy

dcc-mcp treats Python 3.7 as a long-term-support profile. There is no calendar expiry. Removing support requires an accepted superseding ADR, a major release, at least 180 days of notice, and a migration path for affected adapters. This constraint exists because Maya 2022, Blender 2.83, MotionBuilder 2022, 3ds Max releases, and studio hosts embed Python 3.7.

The source of truth is compatibility/python.json; ADR 011 explains the trade-offs. Run vx just check-python-support after changing packaging, PyO3, Python metadata, CI workflows, or these policy documents.

Why This Matters for Review/Release Agents

RoleWhat to check
Merge/review gateRequire the stable Python 3.7 compatibility aggregate status. Reject skipped constituents or a lite-only proof.
ReleaseConfirm native Linux + Windows cp37 wheels, the lite fallback, and requires-python = ">=3.7".
PR authorRun the contract check and real Python 3.7 smoke; CI owns native cross-platform builds and the full suite.
Skill creatorSet compatibility: "dcc-mcp-core <version>, Python 3.7+" in SKILL.md frontmatter.

What is NOT valid

  • py37-lite alone — native py37 builds are required for merge and release gates. A lite wheel that drops Rust extensions is a supported fallback, not proof of the full package contract.
  • py38-only CI passing — all changes that affect the Python surface must have a passing py37 CI job.
  • "Nobody uses py37 anymore" — usage assumptions do not supersede ADR 011. Follow the formal deprecation requirements instead.

PyO3 / Maturin Constraints

  • PyO3 must remain on the series declared in compatibility/python.json unless the proposed upgrade passes both native Python 3.7 build/runtime jobs.
  • Any Cargo.toml change touching pyo3 or maturin version pins requires explicit py37 CI validation in the same PR.
  • When adding a new Rust extension to an existing adapter, verify that maturin build succeeds on Python 3.7 with the current PyO3 pin.

CI Configuration

The CI workflow (.github/workflows/) must include Python 3.7 jobs for PRs that touch:

  1. Cargo.toml / Cargo.lock — workspace-level or per-crate dependency changes
  2. pyproject.toml — build system, dependencies, classifiers
  3. python/ — any Python source or stub file
  4. .github/workflows/ — CI workflow changes that could affect test matrix
  5. crates/ — Rust source that affects the PyO3 bridge

The native Linux py37 job must run the full test suite. The native Windows job must build, install, validate wheel contents, and run the contracted import and behavior smoke. The lite job must separately prove fallback behavior. If a test genuinely cannot run on 3.7 (e.g. depends on Python 3.8+ only features), it should be marked with @pytest.mark.skipif(sys.version_info < (3, 8), ...) and the skip must be documented in the PR.

Released under the MIT License.