# AGENTS.md — dcc-mcp-core

> **Navigation map, not a reference manual.**
> Follow the links; don't read everything upfront. Keep detailed API guidance
> in `llms.txt` / `llms-full.txt` and detailed human-readable explanations in
> `docs/guide/*`. Agent-specific files (`CLAUDE.md`, `GEMINI.md`, `COPILOT.md`,
> `CODEBUDDY.md`) intentionally point back here.
> Detailed rules, traps, and code examples → [`docs/guide/agents-reference.md`](docs/guide/agents-reference.md)

**🤖 New to this project?** Start with [`AI_AGENT_GUIDE.md`](AI_AGENT_GUIDE.md) — a dedicated guide teaching AI agents how to effectively use dcc-mcp-core.

## ⚡ Critical: Skills-First Philosophy

**When interacting with DCC applications (Maya, Blender, Houdini, etc.), ALWAYS prefer dcc-mcp-core Skills over raw CLI or scripting.**

Natural DCC intent is enough to trigger the default `dcc-mcp` skill. If a user
asks to create, edit, inspect, animate, render, composite, or export something
in Maya, Blender, Houdini, Photoshop, 3ds Max, Nuke, Unreal, Substance 3D, or
another supported host, load `dcc-mcp` first even when the user never says
“MCP”. Inventory, search, describe, and call the structured DCC tool before
falling back to raw scripting or scoped Computer Use.

**Why?** Skills provide:
- ✅ Structured results with validation
- ✅ Safety hints (`ToolAnnotations`)
- ✅ Follow-up guidance (`next-tools`)
- ✅ Progressive loading (load only what you need)
- ✅ Audit logs and traceability

**Don't** call Maya/Blender/Python scripts directly — use `search_skills()` → `load_skill()` → call the tool.

## 🚀 Agent Entry Strategy: CLI+REST (default) vs IDE MCP

**dcc-mcp-core offers two entry paths with a clear layered design. Choose based on your runtime:**

| Scenario | Recommended Path | How it works |
|----------|-----------------|--------------|
| AI agent (OpenClaw, Hermes, Codex CLI, custom agent runtime) | **CLI+REST** | One `dcc-mcp` skill → shell `dcc-mcp-cli` → gateway `POST /v1/{search,describe,call}` |
| IDE user (Cursor, Claude Desktop, VS Code) | **IDE MCP** | Manual `mcp_servers.json` / `claude_desktop_config.json` → gateway MCP tools (`search`/`describe`/`call`) |
| CI/CD / automation script | **CLI+REST** | `dcc-mcp-cli health/list/smoke/search/call/load-skill` — scriptable, auditable |
| Troubleshooting / operations | **CLI+REST** | `dcc-mcp-cli` with structured output and exit codes |
| Studio / team integration | **CLI+REST** | Fork `dcc-mcp` skill → one skill controls all DCCs, no per-DCC MCP server config |
| GUI artist using DCC plugin directly | **IDE MCP** | DCC's built-in MCP plugin exposes tools directly to the IDE |

**Core principle:**
- **AI agents** start with `dcc-mcp` skill + `dcc-mcp-cli` CLI. The CLI handles gateway lifecycle (ensure, health, start) and exposes `search → describe → call` via shell.
- **Human IDE users** continue using MCP configuration. The gateway serves both paths simultaneously.
- **CLI-first does not deprecate MCP** — the gateway always exposes both MCP and REST side by side.

**CLI availability and updates:**
- If `dcc-mcp-cli` is missing, obtain user consent before running an official
  installer from `scripts/install-cli.sh` or `scripts/install-cli.ps1`.
- Keep official builds current with `dcc-mcp-cli update check`, then
  `dcc-mcp-cli update apply`. The apply step stages the latest CLI for the next
  launch; it does not replace a running `dcc-mcp-server`.

## Response Language

- Reply to the user in **Simplified Chinese** (中文简体) by default.
- Keep all code, identifiers, commit messages, branch names, docstrings,
  comments, and file contents in **English** — this rule governs only the
  conversational/assistant-facing output, not anything written to disk or
  pushed to git.
- If the user explicitly requests another language for a specific reply,
  follow that request for that turn.

## Command Efficiency

- Prefer `vx rg`, `vx git`, and `vx gh` for routine repository search, git,
  and GitHub CLI operations in this repo. These wrappers keep tool resolution
  stable and produce compact, predictable output for agents.
- Use direct `rg`, `git`, or `gh` only when the `vx` wrapper would hide behavior
  you need to inspect or is unavailable; mention that deviation in validation
  notes when it matters.

## PR / Commit Authorship

- **Do not append AI-attribution footers** to PR bodies, commit messages,
  issue comments, or any other artefact pushed to GitHub. This includes
  (but is not limited to) phrases like
  `"Pull Request opened by Augment Code with guidance from the PR author"`,
  `"Co-authored-by: Augment <…>"`, `"Generated by …"`, etc.
- The author of the change is the human contributor whose git identity
  signs the commit; the assistant is a tool, not a co-author.
- If a PR-creation tool injects such a footer automatically, strip it
  before submitting (or edit the PR body afterwards to remove it).

## PR Merge Workflow

- **Always rebase onto the latest `main` before merging.** Never use a
  merge commit to bring `main` into a feature branch, and never let
  GitHub's "Update branch" button fast-forward via a merge commit.
- For stacked PRs (a branch based on another open PR), rebase the
  downstream branch onto `main` as soon as the upstream PR merges, then
  force-push so the diff collapses to just the new work.
- Final history on `main` must be linear: every PR lands as one (or a
  few) clean commits on top of `main`, no "Merge branch …" noise.

## Document Hierarchy

| Layer | File | When to read it |
|-------|------|-----------------|
| Navigation | `AGENTS.md` (this file) | First contact |
| AI agent entry-point stubs | `CLAUDE.md`, `GEMINI.md`, `COPILOT.md`, `CODEBUDDY.md` | Thin shims — each file just redirects its respective agent to **this** `AGENTS.md`. Edit them **only** to record agent-specific divergences; keep all shared guidance here so it stays single-sourced. |
| AI-friendly index | `llms.txt` | When you need to *use* APIs |
| Full index | `llms-full.txt` | When `llms.txt` lacks detail |
| Detailed rules | [`docs/guide/agents-reference.md`](docs/guide/agents-reference.md) | Before writing code — traps, do/don't, code style |
| Conceptual docs | [`docs/guide/INDEX.md`](docs/guide/INDEX.md) + `docs/api/` | Building a new adapter or skill — see INDEX.md for topic list |
| Skill authoring | [`skills/dcc-mcp-skills-creator/SKILL.md`](skills/dcc-mcp-skills-creator/SKILL.md) + `examples/skills/` | Creating or modifying skills |
| Adapter developer guidance | [`skills/dcc-mcp-creator/SKILL.md`](skills/dcc-mcp-creator/SKILL.md) | Before creating or changing DCC adapter server/runtime wiring, dispatcher bridges, readiness, resources, gateway behavior, or core-escalation plans |
| Skill creator guidance | [`skills/dcc-mcp-skills-creator/SKILL.md`](skills/dcc-mcp-skills-creator/SKILL.md) | Before creating or changing adapter skill authoring, tool schemas, scripts, skill taxonomy, testing, or agent-facing workflows |
| CLI + marketplace operations | [`skills/dcc-mcp/SKILL.md`](skills/dcc-mcp/SKILL.md) | Agents doing DCC control via `dcc-mcp-cli` — gateway ensure, inventory, search, describe, call, marketplace install/update |
| Marketplace extension publishing | [`skills/marketplace-publish-extension/SKILL.md`](skills/marketplace-publish-extension/SKILL.md) | Before publishing a new skill package to the DCC-MCP marketplace |
| Marketplace extension creation | [`skills/marketplace-create-extension/SKILL.md`](skills/marketplace-create-extension/SKILL.md) | Before creating a new marketplace extension package |
| Gateway REST regressions (VRS) | [`tests/vrs/README.md`](tests/vrs/README.md) + `scripts/vrs_replay.py` | After gateway `/v1/*` or live-adapter bugs — add a JSONL trace per regression |

---

## Agent-Facing Skill Sync

- After any code change, check whether the installed adapter and skill guidance
  in `skills/dcc-mcp-creator/` or
  `skills/dcc-mcp-skills-creator/` needs to change.
- If the change affects public APIs, adapter/server wiring, dispatcher
  contracts, readiness, resources, gateway behavior, skill authoring rules,
  tool/resource/prompt schemas, testing expectations, or agent workflow, update
  the relevant skill guidance in the same PR.
- If no update is needed, mention that explicitly in the PR validation notes.

## Quick Orientation

**What**: Rust-powered MCP library for DCC software (Maya, Blender, Houdini, Photoshop…). PyO3/maturin. Zero Python runtime deps. MCP 2025-03-26 Streamable HTTP.

**Why this project matters for AI agents**: dcc-mcp-core provides the **bridge between AI agents and DCC applications**. When you need to interact with Maya, Blender, Houdini, or any other DCC tool, **ALWAYS use dcc-mcp-core Skills** — they provide structured, validated, and safe MCP tools with built-in follow-up guidance.

### 🎯 Skills-First Workflow (MEMORIZE THIS)

**The default path for AI agents is CLI+REST through the gateway.** The per-DCC MCP server path is for IDE users and legacy setups.

```
Gateway CLI+REST (agent default — use dcc-mcp skill + dcc-mcp-cli):
Support check (only when unclear): `dcc-mcp-cli dcc-types` lists canonical adapter-backed identifiers from the release catalog without starting a gateway; use `list` for live sessions.
1. Discover: `dcc-mcp-cli search --query "keyword" --dcc-type maya` or `POST /v1/search` → get `tool_slug`; names/summaries are tokenized, so underscores are optional (`create_sphere` and `sphere` both work).
2. Inspect: `dcc-mcp-cli describe <slug>` or `POST /v1/describe` → read schema + annotations.
3. Execute: `dcc-mcp-cli call <slug> --json '{"radius": 2.0}'` or `POST /v1/call`.
4. Batch execute: `dcc-mcp-cli call --batch --steps '<json-array>'` or `POST /v1/call_batch` for ordered batches (max 25).
5. Package as a skill: fork or extend `dcc-mcp` to reuse `dcc-mcp-cli` calls as structured MCP tools.
6. Review reusable friction only after acceptance: query narrowly scoped `dcc-mcp-cli stats`, then use the `review_skill_improvement` prompt from `dcc-mcp-skills-creator`; zero calls means no evidence, and review never expands edit/publish authority.
7. The gateway never fans out per-tool backend tools into `tools/list`; the advertised gateway MCP surface is read-only discovery (`search`, `describe`) only.
8. Use `ui-control` Computer Use only when a structured DCC skill/script/API is unsupported, missing, or cannot reach the required semantic UI. Never switch to another UI/input path after a policy, authorization, authentication, security, confirmation, desktop-availability, or user-interruption failure. The adapter/operator must bind native input to its DCC with `DCC_MCP_UI_CONTROL_UIA_PROCESS_ID` or `DCC_MCP_UI_CONTROL_UIA_WINDOW_HANDLE`; requests may only narrow that trusted scope. Use `snapshot` → one `act` → `snapshot`, then `stop_computer_use` on every exit path.
9. The user presses `Esc` to stop UI Control across DCC adapter processes. After the hard stop, do not retry, change `session_id`, or restart. Resume only with `ui_control__snapshot(resume_computer_use=true)` after an explicit user request and only while no Computer Use owner is active.
10. Treat `desktop_unavailable` as a pause when Windows is locked, disconnected, or on a secure desktop. No UIA/raw input runs, but the logical `session_id` remains and structured DCC/MCP calls may continue subject to host readiness. Never automate LockApp. After unlock, discard old ids and take a fresh exact-target snapshot so the banner returns.
11. An ordinary process cannot show the banner on the Windows lock screen. Use a dedicated, always-unlocked VM for uninterrupted Windows GUI control. Public `openai/codex` `codex-rs` contains policy/plugin wiring, not the desktop Computer Use helper implementation; this repository's backend is independent.
12. Computer Use executes on the adapter host in the interactive Windows logon session that owns the DCC. A central gateway routes calls but does not own coordinates. Never reuse gateway, other-host, or other-session coordinates. RDP disconnect/session switch pauses with `desktop_unavailable`; reconnect to the DCC session and take a fresh snapshot.
13. Multi-monitor observations remain bounded to the scoped target window, which may sit at a negative virtual-desktop origin or span monitors with different DPI values. Any topology, resolution, or DPI/scaling change invalidates the observation and requires a fresh snapshot. The banner follows the scoped target within its owning session.
14. Windows plug-in setup may use `ui_control__system_operation` only for an exact operator-owned HKCU String/DWORD or file/directory symlink grant. It is windowless and always confirmed, but never permits shell commands, arbitrary file operations, alternate registry hives, overwrite/delete, credentials, elevation, UAC, or security surfaces. Stop on `elevation_required`, `approval_required`, or `system_operation_not_granted`.

IDE MCP (for human IDE users — Cursor, Claude Desktop, VS Code):
1. Discover: MCP `search(query="keyword", dcc_type="maya")` → get `tool_slug`.
2. Inspect: MCP `describe(tool_slug=...)` → read schema + annotations.
3. Execute through MCP: `call(tool_slug, arguments)` or REST `POST /v1/call` / `POST /v1/call_batch` (max 25).
4. Hidden MCP compatibility routes still accept older `search_tools` / `describe_tool` / `call_tool` / `call_tools` names.

Per-DCC MCP server (legacy / direct DCC connection):
1. Discover: search_skills(query="keyword") → find the right skill
2. Activate: load_skill("skill-name") → expose the tools and cascade-activate declared tool groups by default
3. Execute: call the specific tool with validated parameters
4. Follow up: check next-tools.on-success for suggested next steps
5. Debug on failure: use dcc_diagnostics__screenshot or audit_log

Gateway instance discovery:
1. Usually skip instance discovery and go straight to `search` / `POST /v1/search` → `describe` / `POST /v1/describe` → `POST /v1/call` (or `/v1/call_batch` when invoking several slugs in order).
2. When you need a concrete DCC session or direct MCP URL, call resources/read with uri="gateway://instances"; rows always include `mcp_url`, `instance_short`, `source`, `source_meta`, and a normalized `dispatch` object (`reported`, `status`, `ready`, failure metadata), and the list payload includes `by_source` counts.
3. For one instance, read gateway://instances/{instance_id}; full UUID, `instance_short`, or any unique ≥4-char UUID prefix works across gateway tools and tool slugs.
4. Instance rows may come from the local FileRegistry (`source: "file"`), HTTP registration (`source: "http"`), relay-backed tunnels (`source: "relay"`), or optional mDNS/DNS-SD LAN discovery (`source: "mdns"`); HTTP rows win conflicts, then relay, then mDNS, then file rows.
5. Do not call legacy list_dcc_instances / get_dcc_instance / connect_to_dcc; #813 removed them from tools/list.

Gateway resources/prompts:
1. List hand-off artefacts with resources/list; gateway-prefixed URIs identify the owning DCC instance.
2. resources/list includes the gateway://instances root pointer only; do not expect every gateway://instances/{id} URI to be enumerated.
3. Read/subscribe with the exact URI returned by resources/list; do not strip `dcc://<type>/<id>` or backend-resource prefixes.
4. Agent workflow guide (MCP + resources + efficiency) lives at **`gateway://docs/agent-workflows`** (gateway-native `resources/read`).
5. Use prompts/list and prompts/get through the gateway when prompt templates are aggregated from multiple DCC instances.
6. Subscribe only to URIs you plan to react to, and unsubscribe when done.

```

### Multi-DCC Guardrails

- This repository must support multiple DCC hosts (Maya, Blender, Houdini,
  Photoshop, ZBrush, Unreal, Unity, Figma, and custom studio hosts). Do not
  introduce Maya-only assumptions in core code, gateway routing, skill
  discovery, auth examples, error messages, or tests.
- Keep DCC identity parameterized (`dcc_name`, `dcc_type`, `app_name`,
  `DccName::parse`) and preserve unknown/custom DCC names instead of rejecting
  them unless a boundary explicitly requires a known host.
- When fixing an issue, add realistic Rust **and** Python regression tests when
  the behavior crosses Rust/Python or downstream APIs. Use real-looking DCC
  examples from at least two host families where practical (for example Maya +
  Photoshop/ZBrush/custom), not only synthetic single-DCC fixtures.
- Skill actions may opt into `enforce_thread_affinity: true`; then dispatch
  rejects calls whose observed thread context does not match `thread_affinity`
  with `THREAD_AFFINITY_VIOLATION`. Use this for audited main-thread-only DCC
  API actions; host-side guards such as `@require_main_thread` still belong
  inside action bodies for defense in depth.
- For MCP/gateway changes, include an E2E-style validation path that exercises
  actual `tools/list` / `tools/call` / REST or gateway routing. If a real DCC is
  unavailable, use the closest executable/server path and state the gap in the
  PR.

**API surface** — read in this order:
1. 🆕 **[`AI_AGENT_GUIDE.md`](AI_AGENT_GUIDE.md)** — **START HERE** for using dcc-mcp-core effectively
2. `llms.txt` — compact AI-friendly API index
3. `llms-full.txt` — complete AI-friendly API index
4. `python/dcc_mcp_core/__init__.py` — every top-level Python re-export
5. Generated stub `python/dcc_mcp_core/_core.pyi` — parameter names/types after a `stub-gen` or development build; do not treat it as checked-in source of truth

---

## Decision Tables — Find the Right API

### What do you need?

| Need | Use this |
|------|----------|
| Expose DCC tools over MCP | `DccServerBase` → subclass → `start()` |
| Apply adapter policy before any skill load | `DccServerBase.set_skill_load_transform(fn)` — runs for Python, MCP `load_skill`, REST `/v1/load_skill`, batch/group loads; use `set_after_load_skill_hook(fn)` only for observation |
| Zero-code tool registration | agentskills.io `SKILL.md` + `metadata.dcc-mcp.tools` → sibling `tools.yaml` + `scripts/` |
| Zero-code static MCP resources | `metadata.dcc-mcp.resources` → `resources/*.resource.yaml` with `source.type: file` |
| Advertise optional adapter runtimes | `metadata.dcc-mcp.runtimes` descriptors (`python_package`, `python_extra`, `binary`, `env_var`, `feature`) → search/detail runtime state (`available` / `degraded` / `missing`) without executing tool scripts |
| Structured results | `success_result()` / `error_result()` |
| Rich error with traceback | `skill_error_with_trace()` |
| Bridge non-Python DCC | `DccBridge` (WebSocket JSON-RPC 2.0) |
| IPC | `IpcChannelAdapter` / `SocketServerAdapter` + `DccLinkFrame` |
| Hand off files between tools | `FileRef` + `artefact_put_file()` / `artefact_get_bytes()` |
| Multi-DCC gateway (three-layer default) | Runtime Supervisor (per-backend guardian) → Central Gateway (machine-wide daemon) → Per-DCC FileRegistry registration; auto-launch with single-flight lock; guard patrol |
| Choose `dcc-mcp-server` run mode | No subcommand or `auto` = per-DCC server + Runtime Supervisor (auto-launches and patrols the machine-wide `gateway` daemon); `serve --no-auto-gateway` = per-DCC server only; `gateway` = machine-wide gateway daemon with no inline DCC execution; `auto --legacy-gateway-election` = per-DCC first-wins election (fallback when `gateway-daemon` feature is off) |
| Discover gateway DCC instances / direct MCP URLs | `resources/read uri="gateway://instances"` or `gateway://instances/{id}`; entries carry `mcp_url` and replace the removed `list_dcc_instances` / `get_dcc_instance` / `connect_to_dcc` tools |
| Register a remote DCC with a gateway that cannot share `FileRegistry` | `POST /v1/instances/register` with `{instance_id, dcc_type, mcp_url, ttl_secs?}`; refresh with `/heartbeat`, remove with `/deregister`; `gateway://instances` marks these rows with `source: "http"` |
| Discover NAT-hidden DCCs through a relay | `GatewayConfig.relay_sources` / `dcc-mcp-server gateway --relay-source ADMIN_URL=PUBLIC_BASE_URL` polls relay `/tunnels`, probes `<PUBLIC_BASE_URL>/tunnel/<id>/mcp`, and exposes healthy rows with `source: "relay"` (#1363) |
| Discover LAN-local DCC MCP endpoints without a shared registry | Build with `mdns`; run DCC servers with `--advertise-mdns` and gateways with `--discover-mdns`; mDNS rows are probed before surfacing and appear with `source: "mdns"` (#1362) |
| Gateway dynamic capabilities | MCP `search` → `describe` for read-only discovery, then REST `/v1/call` / `POST /v1/call_batch` for execution |
| Gateway resources/prompts | `resources/list` / `resources/read` with exact gateway-returned URIs; `prompts/list` / `prompts/get` for aggregated backend prompt templates |

| Persist project state | `DccProject.open/load(...)` + `register_project_tools(server, ...)` exposing `project.save/load/resume/status` |
| Remote MCP relay (zero-config tunnel) | `RelayServer::start(RelayConfig, agent_bind, frontend_bind).await` — accepts agent registrations on `agent_bind`, multiplexes remote-client TCP from `frontend_bind` to the agent's local MCP server (issue #504) |
| Enable WS/HTTP frontend / `/tunnels` admin | `RelayServer::start_with(cfg, agent_bind, frontend_bind, OptionalBinds { ws_frontend, admin })` — opt-in WS upgrade endpoint at `/tunnel/<id>`, HTTP proxy at `/tunnel/<id>/...`, and read-only `GET /tunnels` + `/healthz` |
| Spawn the local tunnel agent | `dcc_mcp_tunnel_agent::run_once(AgentConfig::new(relay_url, jwt, dcc, local_target)).await` — registers, holds the connection open, bridges per-session bytes to the local DCC HTTP server, and may advertise `instance_id`, `capabilities_fingerprint`, `adapter_version`, and `scene` |
| Long-lived agent with back-off | `dcc_mcp_tunnel_agent::run_with_reconnect(cfg, shutdown_rx).await` — wraps `run_once` in a reconnect loop honouring `AgentConfig::reconnect` (Constant or Exponential); fails fast on `Rejected` |
| Mint a tunnel JWT | `dcc_mcp_tunnel_protocol::auth::issue(&TunnelClaims { sub, iat, exp, iss, allowed_dcc }, secret)` — relay uses `auth::validate` to enforce DCC scope on every registration |
| Gateway lifecycle (idle shutdown) | `DCC_MCP_GATEWAY_PERSIST=1` keeps daemon alive with no backends; `DCC_MCP_GATEWAY_IDLE_TIMEOUT_SECS` controls grace period before shutdown (standalone `gateway` CLI default `30`, daemon auto-ensure default `300`) |
| Gateway failover | `DccGatewayElection(dcc_name, server)` — auto-promote on gateway failure (legacy election mode) |
| Hide unknown DCC types from gateway | `McpHttpConfig.allow_unknown_tools = false` (default) — drops tools whose `dcc_type` is not registered with the gateway (#553, #555) |
| Auto-evict dead gateway instances | Gateway runs a TCP probe loop; deregisters after 3 consecutive failures, also runs a startup probe to evict instances whose listener died while the registry entry survived (#551, #552, #556) |
| Crash-safe heartbeat | `FileRegistry::heartbeat` writes via `tempfile::persist` + Windows `LockFileEx` so concurrent processes can't stomp each other's entry (#554) |
| Remote HTTP registration heartbeat | `POST /v1/instances/heartbeat` refreshes TTL-scoped HTTP rows; the shared `live_instances` view merges them with FileRegistry rows, and HTTP rows win conflicts on identical `instance_id` (#1361) |
| Optional mDNS/DNS-SD discovery | `GatewayConfig.discover_mdns` / `dcc-mcp-server gateway --discover-mdns` browses `_dcc-mcp._tcp.local`; `dcc-mcp-server serve --advertise-mdns` publishes the DCC endpoint; HTTP registration wins over relay, relay wins over mDNS, mDNS wins over file rows (#1362, #1363) |
| Crash-resilient liveness check | `FileRegistry` sentinel lock files — `read_alive()` / `prune_dead_entries()` evict rows whose owner process released its OS-held lock (#748) |
| Default rolling file logging | `default_file_logging_config()` from `dcc_mcp_logging::file_logging` — rolling daily files under the platform log dir (#557) |
| Trim old log files | `prune_old_logs(retention_days, max_total_size_mb)` — call on a schedule or at startup to enforce retention (#558) |
| Prometheus `/metrics` (gateway) | Build `dcc-mcp-http` with `--features prometheus`; `attach_gateway_metrics_route` + `dcc_mcp_telemetry::PrometheusExporter` expose `dcc_mcp_instances_total{status}`, `dcc_mcp_tools_total{dcc_type}`, request duration / failure counters at `GET /metrics` (#559) |
| Skill scoping | `SkillScope` (Repo → User → Team → System → Admin) |
| Lexical + vector skill search (Python) | `RrfFusionIndex().register("lex", LexicalSkillIndex()).register("vec", VectorSkillIndex())` — fuses BM25 with hashing-trick dense vectors so morphology variants and inflected queries (`rendering` vs `render`) still recall the right skill (#1333 + #1393) |
| Local-only dense embeddings, zero-dep default | `VectorSkillIndex()` ships `HashedEmbedder` + `InMemoryVectorStore` by default — no ONNX / FAISS in the base wheel. Install `pip install 'dcc-mcp-core[semantic]'` to enable `OnnxEmbedder`: the three-tier backend prefers the Rust-native `dcc-mcp-core-semantic` companion wheel (fastembed-rs + ONNX Runtime, releases the GIL during inference, #1395), falls back to the pure-Python `fastembed` package, and finally raises `EmbedderError`. Override model / cache via `DCC_MCP_EMBED_MODEL` and `DCC_MCP_EMBED_MODEL_DIR` env vars so studios can pre-place the ONNX model on a shared mount (#1393) |
| Progressive tool exposure | `SkillGroup` + `activate_tool_group()` |
| Declarative progressive loading on startup | `MinimalModeConfig(skills=…, deactivate_groups=…)` → pass to `register_builtin_actions(minimal_mode=…)` (#525) |
| Connection-scoped cache | `McpHttpConfig(enable_tool_cache=True)` — per-session `tools/list` snapshot (#438) |
| Instance-bound diagnostics | `DccServerOptions.from_env(..., dcc_pid=pid)` → `DccServerBase(opts)` |
| Run code on DCC exit | `DccServerBase.register_quit_hook(callback)` — LIFO best-effort hooks run on `stop()`, context-manager exit, and atexit fallback (#747) |
| Defensive Python handle cleanup | `McpHttpConfig(shutdown_on_drop=True)` or `with server.start() as handle:` — safety net for forgotten `handle.shutdown()` (#749) |
| Remote auth | `ApiKeyConfig` / `OAuthConfig` / `validate_bearer_token` |
| Batch / orchestration | `batch_dispatch()`, `EvalContext`, `DccApiExecutor` |
| Mid-call user input | `elicit_form()` / `elicit_url()` |
| Rich content results | `skill_success_with_chart/table/image` |
| Plugin bundle | `build_plugin_manifest()` / `server.plugin_manifest()` |
| In-process skill execution (embedded DCC) | `SkillCatalog.set_in_process_executor(callable)` |
| In-process skill execution wired through DccServerBase | `DccServerBase.register_inprocess_executor(dispatcher=...)` (#521); pass a `BaseDccCallableDispatcher` to route onto the host UI thread |
| Full callable-payload dispatch protocol for DCC plugins | `BaseDccCallableDispatcherFull` + `BaseDccPump` + `JobOutcome` / `PendingEnvelope` (#520); use `InProcessCallableDispatcher` as reference impl for `mayapy` / headless / pytest |
| Bind adapter readiness to MCP/REST | `AdapterReadinessBinder.bind_inline(server)`, `.bind_headless(server)`, or `.bind_queue_dispatcher(server, dispatcher, require_first_pump=True)` publish one `ReadinessProbe` through `DccServerBase.set_readiness_probe()` / `McpHttpServer.set_readiness_probe()` so MCP `tools/call`, REST `/v1/readyz`, and REST `/v1/call` share state (#1206) |
| Skill scanning | `scan_and_load(dcc_name=...)` → always unpack `(skills, skipped)` tuple |
| Tolerate broken SKILL.md | `scan_and_load_lenient(...)` instead of `scan_and_load` |
| Fail-fast on broken SKILL.md | `scan_and_load_strict(...)` — raises `ValueError` listing every skipped directory (issue maya#138) |
| Stamp gateway sentinel for election | `McpHttpConfig(..., adapter_version=..., adapter_dcc=...)` or `.with_adapter_version().with_adapter_dcc()` (issue maya#137) |
| Discover team-level skills | `scan_and_load_team()` / `scan_and_load_team_lenient()` |
| Discover user-level skills | `scan_and_load_user()` / `scan_and_load_user_lenient()` |
| Pick up admin-UI-added skill paths in a running adapter | `DccServerBase.reload_skill_paths()` re-runs discovery with the gateway admin SQLite `skill_paths_custom` table merged in (#1400). The merge happens automatically inside `collect_skill_search_paths(include_admin_custom=True)` on every startup; `reload_skill_paths()` is the runtime trigger for re-scanning after a path is added through the dashboard. Read the raw rows via `dcc_mcp_core.read_custom_skill_paths()` / `resolve_admin_db_path()` |
| Re-scan a running adapter's skill dirs from the gateway, no restart | Every `DccServerBase` auto-registers the `dcc_admin__reload_skills` tool in `register_builtin_actions`. The elected gateway pushes a best-effort `tools/call` for it to all live backends whenever an operator adds/removes a path **or just opens** the admin skill-paths panel (`GET /admin/api/skill-paths` now triggers a reload), so freshly dropped `~/.dcc-mcp/<dcc>/skills` packages appear without a restart. Event-driven only — zero idle cost (no file watcher / polling). |
| Surface broken / non-compliant skills | `DccServerBase.reload_skill_paths_report()` returns `{"count", "skipped"}`; the inner core exposes `McpHttpServer.discover_report()`. Skipped directories (missing/invalid `SKILL.md`) are logged at WARNING in the adapter and folded into the gateway admin operator note so an operator can see *why* an expected skill stayed missing (maya#138). |
| Persist `SkillCatalog.loaded` + active groups across restarts | `DccServerBase.enable_skill_load_persistence(policy="skip_on_drift")` (#1405) wires the catalog's after-load / after-unload / after-group-change hooks to a [`LoadedStateStore`](python/dcc_mcp_core/loaded_state_store.py) backed by `~/.dcc-mcp/<dcc>/loaded.json` (source of truth) and the gateway admin SQLite `skill_loaded_state` / `skill_active_groups` tables (best-effort mirror for admin UI visibility). Call after `start()` so the catalog has finished discovery before the persisted snapshot is replayed via `SkillCatalog.replay_loaded(state_json, policy)`. Policies: `"skip_on_drift"` (default), `"require_exact_version"`, `"ignore_version"` — see [`LoadReplayPolicy`](crates/dcc-mcp-skills/src/catalog/persistence.rs) |
| Bridge stdio MCP server to HTTP/SSE | `dcc-mcp-server translate --stdio "cmd" --app-type foo --port N` |
| Add audit/quota/redact to all gateway calls | `GatewayConfig::middleware_chain` + `AuditMiddleware` / `QuotaMiddleware` / `RedactionMiddleware` |
| Mount OpenAPI REST API as MCP tools | `GatewayBuilder::mount_openapi(OpenApiMount::from_url(...).auth(...))` |
| Enable built-in gateway admin dashboard | Default ON for the elected gateway: open `GET /admin`; JSON APIs include `/admin/api/{instances,tools,calls,traces,stats,workers,logs,health}` plus `/admin/api/traces/{request_id}` detail; `/logs` merges contention events, `DCC_MCP_LOG_DIR` `*.log` rows, and audit call summaries; set `DCC_MCP_GATEWAY_AUDIT_DIR` for durable `audit.jsonl` + `traces.jsonl`; disable with `--no-admin`, `DCC_MCP_NO_ADMIN=true`, or `cfg.admin_enabled = False` |
| Wire OTLP distributed tracing to Jaeger/Tempo/Grafana | Set `OTEL_EXPORTER_OTLP_ENDPOINT` env var at server startup |
| Configure admin integrations (Sentry, webhooks, OTLP) | Set env vars (`DCC_MCP_SENTRY_DSN`, `DCC_MCP_WEBHOOKS_CONFIG`, `OTEL_EXPORTER_OTLP_ENDPOINT`) at server startup; view or change pending restart through the Admin UI → Integrations panel when enabled |
| Read gateway contention event history | MCP `resources/read` on `resources://gateway/events` |
| Search public DCC-MCP adapter catalog | MCP `resources/read` on `gateway://catalog?query=...` (or `gateway://catalog/{name}` for a single entry); CLI: `dcc-mcp-server catalog search --query ...` |
| Disable evolved skills | `ENV_DISABLE_ACCUMULATED_SKILLS` |
| Make skill discovery hermetic in CI/tests | `DCC_MCP_DISABLE_DEFAULT_SKILL_PATHS=1` excludes implicit operator-owned roots (local/platform defaults, marketplace installs, and Admin custom paths) while preserving explicit, bundled, and `DCC_MCP_*_SKILL_PATHS` paths |
| MCP HTTP (recommended) | `create_skill_server("<dcc>", McpHttpConfig())` — OS-assigned instance port; CLI/gateway discovery |
| MCP HTTP (manual) | `McpHttpServer(registry, McpHttpConfig())` — pass `port=N` only for an explicit fixed listener |
| Full-screen capture | `Capturer.new_auto().capture()` |
| Single-window capture | `Capturer.new_window_auto().capture_window(...)` |
| Capture DCC output streams | `OutputCapture` — stdout/stderr/script-editor as `output://` resource |
| Register a custom DCC resource | Prefer a module-level helper first: `register_docs_resource(server, ...)` for `docs://` payloads, `register_adapter_instruction_resources(server, ...)` for adapter instructions. For `DccServerBase`, use `server.register_resource_producer(...)`, `server.set_scene_resource(...)`, `server.notify_resource_updated(...)`, or the shared `server.resources()` handle. Only drop to low-level `server.resources().register_producer("maya-cmds://", cb)` (also `set_scene({...})`, `notify_updated(uri)`, `register_output_buffer(capture)`) when no helper fits the scheme (#730, #1205). Never reach into `server._server.*` — that is private. |
| Cooperative cancellation (MCP request) | `check_cancelled()` in long-running skill scripts |
| Cooperative cancellation (DCC dispatcher + MCP) | `check_dcc_cancelled()` — combines MCP token + per-job `JobHandle` (#522) |
| Detect a misconfigured GUI binary as `DCC_MCP_PYTHON_EXECUTABLE` | `is_gui_executable(path)` → `GuiExecutableHint(dcc_kind, recommended_replacement)` (#524) |
| Auto-correct GUI binary to its headless sibling | `correct_python_executable(path)` — falls back to original path if no sibling found (#524) |
| Checkpoint/resume | `save_checkpoint(job_id, state)` / `get_checkpoint(job_id)` |
| Job recovery policy on restart | `McpHttpConfig.with_job_recovery(JobRecoveryPolicy::Drop\|Requeue)` / Python `cfg.job_recovery = "drop"\|"requeue"` — `Requeue` is reserved (degrades to `Drop` + `WARN` until tool-arg persistence lands) (#567) |
| Persistent workflow idempotency cache | `WorkflowExecutor::builder().idempotency_store(SqliteIdempotencyStore::new(workflow_storage)).build()` — survives restarts; honours per-step `idempotency_ttl_secs`; cascade-deletes workflow-scoped rows when their parent workflow row is removed (#566, gated on `dcc-mcp-workflow/job-persist-sqlite`) |
| Resume a persisted workflow run | `executor.resume(workflow_id, ResumeOptions { force_steps, expected_spec_hash, strict })` / MCP tool `workflows.resume`. Skips steps already recorded `completed`; re-runs `force_steps`; refuses on hash drift when `strict=true` (#565, gated on `dcc-mcp-workflow/job-persist-sqlite`) |
| Agent-facing docs resources | `register_docs_server(server)` → `docs://` MCP resources |
| Headless USD project resources | `register_usd_project_resources(server, project_root=..., stage=..., layers=...)` → canonical `openusd://stage`, `openusd://layers`, `openusd://assets`, `openusd://materials`, `openusd://validation`, `openusd://snapshots`, and `openusd://packages` resources with stable MIME metadata (#1209) |
| Agent feedback | `register_feedback_tool(server)` → `dcc_feedback__report` tool |
| Runtime introspection | `register_introspect_tools(server)` → `dcc_introspect__*` tools |
| Skill recipe lookup | `register_recipes_tools(server, skills=...)` |
| YAML workflow definitions | `load_workflow_yaml(path)` / `register_workflow_yaml_tools(server)` |
| Skill hot-reload | `DccSkillHotReloader(dcc_name, server).enable(paths)` |
| Singleton server factory | `make_start_stop(ServerClass)` → `(start_fn, stop_fn)` |
| Skill validation | `validate_skill(skill_dir)` → `SkillValidationReport` |
| Zero-dep JSON/YAML | `json_dumps/loads` / `yaml_dumps/loads` (Rust-powered) |
| Canonical MCP/REST call envelopes | Rust `dcc-mcp-wire::{decode_call_tool, decode_rest_call, normalize_arguments, normalize_meta}`; Python wrappers `dcc_mcp_core.host.normalize_tool_arguments()` / `normalize_tool_meta()` |
| Typed handler return envelope | `ToolResult.ok("msg", **ctx).to_dict()` / `ToolResult.fail("msg", error="code").to_dict()` from `dcc_mcp_core.result_envelope` — note the underscored aliases `success_`/`error_`; `success`/`error` are dataclass fields, **not** factories (#487) |
| Centralised metadata keys | `from dcc_mcp_core import METADATA_*, LAYER_*, CATEGORY_*` — re-exported at top level; also available from `dcc_mcp_core.constants`. Never inline `"dcc-mcp.recipes"` etc. (#487) |
| Custom JSON-RPC method (Rust) | `MethodRouter::register(method, Arc::new(handler))` — implement `MethodHandler` trait (#492) |
| Custom action validation (Rust) | implement `ValidationStrategy` and return it from `select_strategy(...)` (#493) |
| Custom version constraint shape (Rust) | implement `VersionMatcher` + wrap in `VersionConstraint::Custom` (#493) |
| Build a registry-like container (Rust) | implement `Registry<V>` over your storage, or use `DefaultRegistry<V>` (#489) |
| Build a JSON-RPC notification (Rust) | `NotificationBuilder::new("notifications/...").with_params(json).as_sse_event()` (#484) |
| Typed DCC name (Rust) | `DccName::parse("Maya")` → `DccName::Maya`; canonicalises + hashes safely (#491) |
| Register lifecycle hooks | `LifecycleHooks()` + `.on(HookEvent.BEFORE_TOOL_CALL, handler)` + `server.register_lifecycle_hooks(hooks)` — typed, fail-safe observer/pub-sub for skill/tool/session events (#1337) |
| Veto a skill load or tool call by policy | Raise `HookDeny(reason, hint=...)` from a `BEFORE_SKILL_LOAD` / `BEFORE_TOOL_CALL` / `BEFORE_SEARCH` handler — only policy events propagate denial; non-policy events swallow it (#1337) |
| Observe skill/tool lifecycle (analytics, audit) | `HookEvent.AFTER_SEARCH` / `AFTER_SKILL_LOAD` / `AFTER_TOOL_CALL` / `SESSION_START` / `SESSION_END` — non-policy events; handlers fire in registration order, exceptions logged & swallowed (#1337) |
| Enrich search context before discovery | `HookEvent.BEFORE_SEARCH` handler mutates `ctx.payload` in-place (add tags, dcc filters); the mutated dict is visible to the search caller (#1337) |
| Three-tier agent memory (ephemeral → working → longterm) | `InMemoryMemoryStore()` + `MemoryRecorder(store).install(hooks)` — session-scoped ring buffers for EPHEMERAL/WORKING, global cap for LONGTERM; auto-compacts working→longterm on SESSION_END (#1334) |
| Inject memory summaries into search/tool-call context | `MemoryRecorder` auto-injects `memory_summary` / `memory_prefer_tools` / `memory_avoid_tools` on `BEFORE_SEARCH` and `BEFORE_TOOL_CALL` (#1334) |
| Query agent memory | `store.query(MemoryQuery(layer=..., session_id=..., key_prefix=...))` → `tuple[MemoryEntry, ...]` sorted by recency then score (#1334) |
| Pluggable memory backend | Implement `MemoryStore` Protocol (`put`, `query`, `forget`) — `InMemoryMemoryStore` is the default; swap in SQLite/file/Redis backends without changing recorder logic (#1334) |
| Register all standard built-in MCP tools at once | `register_all_builtin_skills(server, dcc_name=..., skills=...)` — registers diagnostics, introspection, feedback, recipes, Qt UI inspector, and script materialization tools; idempotent (#1332) |
| Structured skill recall metadata | `RecallContext`, `Precondition`, `SideEffects`, `ToolRole`, `RiskLevel` on `SkillMetadata` / `ToolDeclaration` — optional fields for ranking, capability graph, and policy decisions (#1335) |
| Associate a tool with its safety role | `ToolRole.ReadOnly` / `Action` / `Destructive` / `EscapeHatch` / `DebugOnly` — ranking demotes `EscapeHatch`, safety surfaces warn on `Destructive` (#1335) |

### Skill layer taxonomy (`metadata.dcc-mcp.layer`)

| Layer | Purpose | `search_skills` rank |
|-------|---------|----------------------|
| `domain` | Pipeline-level intent (shot export, render farm) | × 1.00 |
| `infrastructure` | Safety, diagnostics, introspection — fallback tier | × 0.35 |
| `thin-harness` | One Python script + minimal SKILL.md — raw `python` / `bash` / CLI wrappers | × 0.20 |
| `example` | Authoring reference only | **excluded from results** |

The ranking rules (#1398) keep low-level skills out of the agent's primary
flow for neutral queries:

- `domain` skills win by default.
- `infrastructure` skills (e.g. `ui-control`, `dcc-adapter`, `dcc-diagnostics`)
  stay around as fallbacks but rank below domain.
- `thin-harness` skills are the lowest tier that still appears — useful as a
  last-resort wrapper around a raw script.
- `example` skills are dropped from results entirely; they only surface when
  the caller explicitly asks for them (see below) or types the exact name.

The penalty and the `example` exclusion are bypassed when the caller filters
by a known layer name through `tags=` (case-insensitive), e.g.
`search_skills(tags=["example"])` or `search_skills(tags=["infrastructure"])`,
so the raw BM25 order is honoured inside the filtered slice.

### Skill path-source rank (#1403)

A second multiplier is layered on top of the layer multiplier based on
where the skill was discovered from. User-curated locations rank at
parity (× 1.00); bundled / platform-installed starter material is
slightly damped so a local-dev skill always wins a tie.

| Source         | Where it comes from                              | Rank   |
|----------------|--------------------------------------------------|-------:|
| `ExplicitArg`  | `extra_paths` passed to `discover()` / `scan_*`  | × 1.00 |
| `AdminCustom`  | Added through the admin UI (gateway SQLite lane) | × 1.00 |
| `EnvVar`       | `DCC_MCP_SKILL_PATHS` / `DCC_MCP_<APP>_SKILL_PATHS` | × 1.00 |
| `LocalDev`     | `~/.dcc-mcp/<dcc>/skills` (local iteration root) | × 1.00 |
| `Platform`     | Platform-wide install dir (`get_skills_dir`)     | × 0.85 |
| `Bundled`      | Shipped with the dcc-mcp package itself          | × 0.70 |

The path-source multiplier compounds multiplicatively with the layer
multiplier (both are `≤ 1.00`). The exact-name fast-path bypasses it —
`search_skills("dcc-diagnostics")` still surfaces a bundled diagnostics
skill at the top regardless of source.

Source tagging happens at scan time in
`crates/dcc-mcp-skills/src/scanner.rs::scan_with_sources`. Adapters can
read the assigned source via `SkillEntry.path_source` for diagnostic
surfaces (e.g. the admin UI's skill panel) — it is stable across
restarts because the field is `#[serde(default)]`.

---

## AI Agent Tool Priority

**CRITICAL: Always prefer dcc-mcp-core tools over direct CLI or scripting.** These tools provide structured results, input validation, safety annotations, and follow-up guidance that raw scripting cannot.

1. **Skill Discovery**: `search_skills(query)` → `load_skill(name)` → use tools
2. **Skill-Based Tools**: Validated schemas + `next-tools` + `ToolAnnotations` safety hints
3. **Diagnostics**: per-DCC `dcc_diagnostics__screenshot` / `dcc_diagnostics__audit_log` MCP tools; gateway-side `gateway://diagnostics/{process,audit,metrics}` MCP resources (`resources/read`).
4. **Direct Registry** (last resort): Validate with `ToolValidator` + sandbox with `SandboxPolicy`

### Why Skills-First?

| Aspect | dcc-mcp-core Skills | Raw CLI / Scripting |
|--------|---------------------|---------------------|
| Input validation | JSON Schema validated | None — garbage in, garbage out |
| Safety | `ToolAnnotations` (read-only, destructive, idempotent) | Unknown |
| Follow-up guidance | `next-tools` chains | Manual discovery |
| Progressive loading | Load only what you need | All or nothing |
| Error recovery | Structured `error_result` with `prompt` suggestions | Unstructured stderr |
| Traceability | Audit log + telemetry | None |

---

## Top Traps — Memorize These

1. **`scan_and_load` returns a 2-tuple** → `skills, skipped = scan_and_load(...)` — never iterate directly
2. **`success_result` kwargs → context** → `success_result("msg", count=5)` — do NOT use `context=`
3. **`ToolDispatcher` uses `.dispatch()`** → never `.call()`
4. **Register ALL handlers BEFORE `server.start()`** — server reads registry at startup
5. **SKILL.md extensions use `metadata.dcc-mcp.<feature>`** → sibling files, never top-level keys (v0.15+ / #356)
6. **Use `dcc_mcp_core.METADATA_*` / `LAYER_*` / `CATEGORY_*`** → re-exported at top level (also in `constants` sub-module); no inline `"dcc-mcp.recipes"` / `"thin-harness"` literals (#487)
7. **Return `ToolResult` from Python tool handlers** → `ToolResult.ok("...", **ctx).to_dict()` (or `success_(...)`); `success`/`error` are dataclass *fields*, the factories are `success_`/`error_` / `ok`/`fail` (#487)
8. **Gateway REST `/v1/call` / `/v1/call_batch` payloads accept only `tool_slug`, `arguments`, and `meta`** → put backend fields (`code`, `file_path`, `radius`, …) inside `arguments`; use `dcc-mcp-wire` / `dcc_mcp_core.host.normalize_tool_arguments()` for shared normalization
9. **Lifecycle hooks — policy events propagate `HookDeny`, observation events swallow it** → `BEFORE_SKILL_LOAD`, `BEFORE_TOOL_CALL`, `BEFORE_SEARCH` are policy; all others are observation-only. Raising `HookDeny` from an observation event is silently logged (#1337)
10. **Lifecycle hooks — `off()` removes by identity (`is`), not equality** → store the handler reference; `hooks.off(event, lambda ctx: ...)` never matches (#1337)
11. **Agent memory — `MemoryRecorder.install()` must be called** → the recorder does nothing until wired to a `LifecycleHooks` instance; forgetting `install()` means zero memory is recorded (#1334)
12. **Agent memory — raw prompts are redacted** → never pass LLM prompts or `api_key`/`password`/`secret`/`token` keys in `MemoryEntry.payload`; the `_safe_payload` filter strips them (#1334)
13. **Phase hook signature mismatch** → `_registration.py` calls 3 hooks with `(context)` and 7 with no args. Overriding a hook with the wrong parameter count silently fails because `run_registration_phases` catches non-fatal exceptions. Always run an integration test that calls `get_standard_phases()` against your real server class (not `MockServer`) and assert no `TypeError` (PIP-2479). See [`tests/test_phase_hook_signature_consistency.py`](tests/test_phase_hook_signature_consistency.py) for the static check.

Full trap list + code examples → [`docs/guide/agents-reference.md`](docs/guide/agents-reference.md)

---

## Verified Regression Suite (VRS) — gateway `/v1/*` replay

Use this when a bug is only visible **through HTTP** (gateway election, routing, error envelopes, DCC adapter behaviour under load) and unit tests are not enough.

### When to add a trace

Whenever you close a regression that involves **gateway REST** or **per-DCC `/v1/*`** behaviour:

1. Open or reference the GitHub issue in the trace header `trace_id` / PR description.
2. Add `tests/vrs/traces/<issue-or-topic>-<short-slug>.jsonl` (one concern per file; keep steps ordered).
3. Prefer `expect_any` when legitimate outcomes differ by transport (e.g. HTTP 200 with `output.success: false` vs 502 `backend-error`).
4. For traces that need a live Maya (or any optional host), put a header `skip_preflight` so CI and agent runs **exit 0 skipped** when `POST /v1/search` shows no matching instance (see `maya-215-execute-python-regression.jsonl`).
5. Extend [`tests/vrs/README.md`](tests/vrs/README.md) index table with: file name, required live DCC?, one-line purpose.

### How to run

```bash
# Live gateway (default port 9765 in examples)
just vrs-replay BASE=http://127.0.0.1:9765 TRACE=tests/vrs/traces/gateway-smoke.jsonl

# Same, explicit script
python scripts/vrs_replay.py --base-url http://127.0.0.1:9765 --trace tests/vrs/traces/<your-trace>.jsonl
```

Validate JSON step order and substitutions **without** a server:

```bash
python scripts/vrs_replay.py --base-url http://127.0.0.1:1 --dry-run --trace tests/vrs/traces/<your-trace>.jsonl
```

Optional: `VRS_HTTP_TIMEOUT_SECS` overrides per-request timeout (default `120`).

Full step schema, `expect` / `capture` / `skip_preflight` → [`tests/vrs/README.md`](tests/vrs/README.md).

---

## Build & Test

`vx just dev` (build wheel) → `vx just test` → `vx just preflight` (pre-commit check + docs dead-link check)

---

## File Size Policy

CI enforces hard line-count limits (`.github/workflows/check-file-size.yml`):

| Language | Limit | Test files |
|----------|-------|------------|
| Rust (`.rs`) | **1 500 lines** | **2 000 lines** |
| Python (`.py`) | **1 000 lines** | excluded |
| Admin UI source/test (`.ts`, `.tsx`, `.css`, `.json`) | **3 000 lines** | included |

### When a file exceeds the limit

Do **not** add it to `.github/file-size-exemptions.txt` unless it is pre-existing
technical debt tracked in an open issue. **Split it instead.**

#### Rust — Clean Architecture split

A file that exceeds 1 500 lines almost always mixes multiple responsibilities.
Split it along these boundaries:

```
crates/<name>/src/
├── domain/        # pure business logic, no I/O, no framework deps
│   ├── model.rs   # types, enums, value objects
│   └── service.rs # domain services (no async unless domain-driven)
├── application/   # orchestration, use-case handlers, coordinator structs
│   └── handler.rs
├── infra/         # I/O: file system, network, OS, external crates
│   ├── store.rs
│   └── transport.rs
└── lib.rs         # re-exports only; no logic here
```

Rules:
- One public type / one cohesive set of related functions per file.
- `domain/` must not import from `infra/` or `application/`.
- `application/` orchestrates `domain/` + `infra/`; no business rules.
- Keep `impl` blocks with their type — do not scatter impls across files.
- Keep Rust test files below 2 000 lines. Extract shared fixtures into focused
  helper modules and split broad integration suites by user-facing workflow.
- Remove code smells during the split: eliminate `unwrap`/`expect` outside
  tests, replace god-structs with focused structs, break cyclic deps.

#### Python — Clean Architecture split

```
python/dcc_mcp_core/<feature>/
├── __init__.py    # public re-exports only
├── _model.py      # dataclasses / TypedDicts / Pydantic models
├── _service.py    # pure business logic
└── _adapter.py    # I/O adapters (files, subprocesses, DCC bridges)
```

Rules:
- Max one public class or one cohesive group of related pure functions per file.
- No circular imports; domain layer must not import adapters.
- Prefer composition over inheritance for adapter variations.

#### Admin UI — feature split

The Admin UI under `admin-ui/src/` should keep source, test, style, and locale
files below 3 000 lines.

Rules:
- Split API/data model types and normalization helpers into focused `.ts`
  modules.
- Split shared view helpers and panel-level components into focused `.tsx`
  modules instead of growing `App.tsx`.
- Split large stylesheets by feature area and import them explicitly from
  `main.tsx` so cascade order stays visible.
- Store translation dictionaries under `admin-ui/src/locales/<locale>/` by
  namespace, for example `locales/en/action.json`, `locales/en/traffic.json`, or
  `locales/zh-CN/skill-paths.json`.

#### Exemption process

If a split is genuinely out-of-scope for the current PR:
1. Open a tracking issue titled `[Refactor] Split <file> into modules`.
2. Add one line to `.github/file-size-exemptions.txt`:
   ```
   # issue #NNN — <reason>
   path/to/the/file.rs
   ```
3. The PR author is accountable for closing that issue within two sprints.

---

## Repo Layout (What Lives Where)

```
crates/          # Rust workspace; package membership comes from Cargo.toml
python/dcc_mcp_core/__init__.py  # ← top-level Python public re-exports
python/dcc_mcp_core/result_envelope.py  # ← typed ToolResult dataclass (#487)
python/dcc_mcp_core/constants.py        # ← metadata key / layer / category constants (#487)
python/dcc_mcp_core/_server/            # ← DccServerBase collaborators (observability, skill_query, window_resolver) (#486)
tests/           # integration/regression tests
examples/skills/ # 15 complete SKILL.md packages
docs/            # human-readable guides + API reference
```

---

## Essential Do / Don't

### Do ✅
- Use `create_skill_server()` — Skills-First entry point
- Use `success_result("msg", count=5)` — kwargs become context
- Use `ToolResult.ok("...", **ctx).to_dict()` (or `.success_(...)`) from `result_envelope`; `.fail(msg, error="...")` for errors; `.not_found("Skill", name)` / `.invalid_input(msg)` for the common error codes (#487)
- Import metadata strings from `dcc_mcp_core` (`METADATA_*`, `LAYER_*`, `CATEGORY_*` re-exported at top level; `dcc_mcp_core.constants.*` also works) (#487)
- Use `ToolAnnotations` — safety hints for AI clients
- Use `search_skills(query)` — don't guess tool names
- Use `metadata.dcc-mcp.<feature>` keys + sibling files for all SKILL.md extensions
- Tag every skill with `metadata.dcc-mcp.layer`
- Unpack `scan_and_load()`: `skills, skipped = scan_and_load(...)`
- Use `DccName::parse(s)` at Rust API boundaries instead of `&str` (#491)
- Keep core code DCC-agnostic; parameterize `dcc_name` / `dcc_type` and cover multi-DCC behavior in Rust + Python tests
- Use `MethodRouter::with_builtins()` then `.register(...)` to add custom JSON-RPC methods (#492)
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`
- Use `vx just dev` before `vx just test`

### Don't ❌ (and what to do instead)
- Don't iterate `scan_and_load()` → **unpack the 2-tuple**
- Don't use `context=` kwarg in `success_result()` → **pass kwargs directly**
- Don't call `ToolDispatcher.call()` → **use `.dispatch(name, json_str)`**
- Don't put SKILL.md extensions at top level → **use `metadata.dcc-mcp.<feature>` + sibling file**
- Don't hand-roll `{"success": ..., "context": ...}` dicts in handlers → **return `ToolResult.ok(...).to_dict()`** (the factory is `ok`/`success_`, NOT `success`) (#487)
- Don't write inline `"dcc-mcp.recipes"` / `"thin-harness"` literals → **import from `dcc_mcp_core`** (constants re-exported at top level) (#487)
- Don't pass raw `&str` DCC names through Rust APIs → **`DccName::parse(s)` at the boundary** (#491)
- Don't hardcode Maya as the default/example for generic core behavior → **use generic DCC examples or at least two DCCs in tests**
- Don't fix issues with Rust-only or Python-only coverage when both surfaces are affected → **add realistic tests on both sides**
- Don't extend the JSON-RPC `match` arm in `dispatch.rs` → **register a `MethodHandler` on `MethodRouter`** (#492)
- Don't hand-roll JSON-RPC envelopes → **`NotificationBuilder` / `JsonRpcRequestBuilder`** (#484)
- Don't add per-crate `*Error` enums → **return `DccMcpError` via `From` impls** (#488)
- Don't break Python 3.7 support → **it is an LTS profile; native Linux + Windows cp37 gates are required, and py37-lite is not a substitute. See ADR 011 and `compatibility/python.json`**
- Don't manually bump versions → **Release Please handles this**
- Don't hardcode scope strings → **use `SkillScope` when introspecting from Python and `SkillMetadata` methods for policy checks**
- Don't add a generic `utils` / `common` / `helpers` crate → **route helpers to their owner: domain crate, `dcc-mcp-paths`, `dcc-mcp-logging`, or `dcc-mcp-pybridge`** ([rationale](docs/guide/agents-reference.md#workspace-boundary-rationale))

Full list with code examples → [`docs/guide/agents-reference.md`](docs/guide/agents-reference.md)

---

## External Standards

| What | Where |
|------|-------|
| MCP spec (2025-03-26) | <https://modelcontextprotocol.io/specification/2025-03-26> |
| SKILL.md format | <https://agentskills.io/specification> |
| AGENTS.md standard | <https://agents.md/> |
| llms.txt format | <https://llmstxt.org/> |
