Skip to content

HTTP API ​

dcc_mcp_core — MCP Streamable HTTP server (2025-03-26 spec).

Overview ​

The dcc-mcp-http crate provides an MCP HTTP server that exposes your ToolRegistry over HTTP. MCP hosts (like Claude Desktop or other LLM integrations) connect via HTTP POST requests to the /mcp endpoint.

Background Thread

The server runs in a background Tokio thread and never blocks the DCC main thread. Safe to use in Maya/Blender/etc. plugins.

McpHttpConfig ​

Configuration for the HTTP server.

Constructor ​

python
from dcc_mcp_core import McpHttpConfig

cfg = McpHttpConfig(
    port=0,                   # default: OS-assigned; set a fixed port when required
    server_name="maya-mcp",   # Name in MCP initialize response
    server_version="1.0.0",    # Version in MCP initialize response
    enable_cors=False,         # CORS headers for browser clients
    request_timeout_ms=30000,  # Per-request timeout in ms
)

Properties ​

PropertyTypeDefaultDescription
portint0TCP port the server is listening on (0 = OS-assigned)
hoststr"127.0.0.1"IP address to bind (localhost only per MCP security spec)
endpoint_pathstr"/mcp"MCP endpoint path
server_namestr"dcc-mcp"Server name in MCP response
server_versionstrpackage versionServer version in MCP response
max_sessionsint100Maximum concurrent SSE sessions
request_timeout_msint30000Per-request timeout in milliseconds
enable_corsboolFalseEnable CORS headers for browser clients
session_ttl_secsint3600Idle session TTL in seconds (0 = disable eviction)
lazy_actionsboolFalseOpt-in: surface only 3 meta-tools (list_actions, describe_action, call_action) instead of all tools in tools/list
gateway_portint9765 (Python)Gateway port to compete for (0 = disabled). See Gateway
admin_enabledboolTrueElected gateway serves the local Admin UI (GET /admin)
admin_pathstr"/admin"URL prefix for the Admin UI
registry_dirstr | NoneNoneDirectory for the shared FileRegistry JSON (defaults to OS temp dir)
stale_timeout_secsint30Seconds without a heartbeat before an instance is considered stale
heartbeat_secsint5Heartbeat interval in seconds (0 = disabled)
dcc_typestr | NoneNoneDCC type reported in registry (e.g. "maya", "blender")
dcc_versionstr | NoneNoneDCC version string reported in registry (e.g. "2025")
scenestr | NoneNoneCurrently open scene file — improves gateway routing
spawn_modestr"dedicated"Listener spawn strategy: "ambient" (standalone binary) or "dedicated" (PyO3-embedded; own OS thread + current_thread runtime). Fixes issue #303
self_probe_timeout_msint200Max ms to wait when self-probing a freshly bound listener. 0 disables. Issue #303 guard
bare_tool_namesboolTruePublish unique action names without <skill>__ prefix in tools/list (#307). Collisions fall back to the client-safe <skill>__<action> form.
enable_tool_cacheboolTrueConnection-scoped tools/list cache (#438). Per-session snapshot avoids redundant registry scans on sequential calls. Invalidated on skill load/unload, group activation/deactivation, session eviction, or _meta.dcc.refresh=true

Adapter tool names ​

Native adapters using DccServerBase can select this policy through the public DccServerOptions before constructing the server:

python
from pathlib import Path

from dcc_mcp_core import DccServerOptions

options = DccServerOptions.from_env(
    "unity",
    Path("skills"),
    bare_tool_names=False,
)

The option accepts only bool values. Its default, True, preserves unique bare action names and uses skill-qualified names for collisions. False retains skill-qualified names such as ui_control__snapshot. The option is forwarded to McpHttpConfig before native server construction; it does not change the DCC identity or disable tools/list pagination. Clients must still follow every continuation cursor and use the exact advertised names and schemas.

Admin persistence

McpHttpConfig controls whether the elected gateway serves /admin; durable admin storage is intentionally environment-driven. Set DCC_MCP_GATEWAY_AUDIT_DIR to persist /admin/api/calls rows in audit.jsonl and /admin/api/traces rows in traces.jsonl; DCC_MCP_GATEWAY_AUDIT_MAX_ROWS caps each file.

McpServerHandle ​

Returned by McpHttpServer.start(). Use it to get the MCP endpoint URL and shut down gracefully.

Alias

McpServerHandle is the preferred public name. The internal ServerHandle remains available as a compatibility alias.

python
from dcc_mcp_core import McpServerHandle  # preferred public handle name

Properties ​

PropertyTypeDescription
portintActual port server is bound to (useful when port=0)
bind_addrstrBind address, e.g. "127.0.0.1:8765"
is_gatewayboolTrue if this process won the gateway port competition
instance_idstr | NoneExact UUID registered in FileRegistry; None when gateway registration is disabled or unavailable

Methods ​

MethodReturnsDescription
mcp_url()strFull MCP endpoint URL, e.g. "http://127.0.0.1:8765/mcp"
shutdown()NoneGraceful shutdown (blocks until stopped)
signal_shutdown()NoneSignal shutdown without blocking

Example ​

python
from dcc_mcp_core import ToolRegistry, McpHttpServer, McpHttpConfig

registry = ToolRegistry()
registry.register("get_scene_info", description="Get current scene info",
                  category="scene", tags=[], dcc="maya", version="1.0.0")

server = McpHttpServer(registry, McpHttpConfig())
handle = server.start()

print(f"MCP HTTP server running at {handle.mcp_url()}")
print(f"Registered service: {handle.instance_id}")
# The handle exposes the OS-assigned listener URL and canonical registry UUID.

# Shutdown when done
handle.shutdown()

The raw handle keeps its cached instance_id after shutdown(). A DccServerBase exposes the same value through server.instance_id only while running: it is None before start(), after stop(), or whenever registration is disabled. Adapters must not manufacture a fallback UUID.

McpHttpServer ​

MCP Streamable HTTP server (2025-03-26 spec).

Constructor ​

python
from dcc_mcp_core import ToolRegistry, McpHttpServer, McpHttpConfig

server = McpHttpServer(
    registry,         # ToolRegistry instance
    config=None,      # McpHttpConfig (defaults to port=0, OS-assigned, no CORS)
)

Methods ​

MethodReturnsDescription
start()McpServerHandleStart server in background thread and return handle
register_handler(tool_name, handler)NoneRegister a Python callable that receives decoded params (typically a dict)
has_handler(tool_name)boolCheck if a handler is registered for a tool
discover(extra_paths, dcc_name)intScan and populate the skill catalog; returns count of discovered skills
load_skill(skill_name)list[str]Load a skill, registering its tools; returns tool names
unload_skill(skill_name)boolUnload a skill, removing its tools
search_skills(query, tags, dcc, scope, limit)list[SkillSummary]Unified skill discovery. All arguments optional; empty call returns the top limit skills by scope precedence (Admin > System > User > Repo).
list_skills(status)list[SkillSummary]List skills with optional status filter ("loaded"/"unloaded")
is_loaded(skill_name)boolCheck if a skill is currently loaded
loaded_count()intNumber of currently loaded skills

MCP Protocol Endpoints ​

The server implements the MCP 2025-03-26 spec:

EndpointMethodDescription
/mcpPOSTMCP request (JSON-RPC 2.0)
/mcpGETSSE-compatible event stream
/mcpDELETETerminate MCP session
/healthGETHealth check with job_persistence state

Request/Response Format ​

MCP requests use JSON-RPC 2.0:

json
// POST /mcp
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}
json
// POST /mcp response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {"name": "get_scene_info", "description": "Get current scene info", ...}
    ]
  }
}

Supported MCP Methods ​

MethodDescription
initializeProtocol handshake, returns server capabilities
tools/listList all registered tools from the registry
tools/callDispatch a tool by name with parameters
resources/listList available resources; gateways include the gateway://instances root pointer
prompts/listList registered prompts; gateways return namespaced backend prompt templates

| ping | Liveness check |

Built-in Tools ​

Every McpHttpServer emits a fixed set of built-in tools in tools/list in addition to whatever is registered on the ToolRegistry or loaded from skills. Their descriptions follow the 3-layer what / When to use / How to use structure defined in issue #341 (capped at 500 chars, enforced by tests/test_tool_descriptions.py).

Discovery & lifecycle (always-on) ​

ToolPurposeTypical follow-up
search_skillsRanked keyword search over discovered skills (name, description, search-hint, tags, tool names). Start here when you don't know the skill name.load_skill(skill_name=...)
list_skillsBrowsable page of discovered skills with load status. Use for browsing, not search.get_skill_info(...) or load_skill(...)
get_skill_infoFull metadata + input schemas for one skill.load_skill(skill_name=...)
load_skillLoads one or more skills; emits tools/list_changed. Idempotent.Call the specific tool by name
unload_skillUnloads a skill; emits tools/list_changed. Idempotent.load_skill(...) again if needed
activate_tool_groupExpands a __group__<name> stub into its member tools.Re-call tools/list, then the tool
deactivate_tool_groupCollapses a tool group back to a stub to shrink the token footprint.activate_tool_group(...)
search_toolsFull-text search over active tools plus unloaded skill candidates, supports multi-word natural-language phrases. Returned without full schemas.get_skill_info(...), load_skill(...), or call the matched active tool
list_rootsReturns the filesystem roots the client advertised via roots/list. Rarely needed.—

list_skills pagination contract ​

list_skills fans out across every live DCC host, so an unbounded result grows with the size of the whole studio rather than with what the caller asked for (PIP-3407). The response is therefore always a bounded page:

ArgumentDefaultBehaviour
limit25Page size. Clamped to a hard maximum of 50; 0 is treated as unset. There is no way to request an unbounded page.
offset0Rows to skip after sorting by name, DCC type, and instance ID. Pass the previous page's next_offset.
statusallloaded for active skills, unloaded for load candidates, skipped for rejected-package diagnostics, error for failures.
include_skippedfalseInclude skipped-skill diagnostics with stable reason codes and suggested fixes.
fieldscompact setStrict allow-list of per-skill columns. Omit for compact mode (name, dcc, summary, tool_count, status, loaded, stage, missing_dependencies).

Response fields:

FieldMeaning
totalFull catalogue size across all hosts, not the page size.
truncatedtrue when more rows exist after this page.
next_offsetPresent only when truncated is true. Pass it as offset for the next page.
next_stepReady-to-follow instruction for the next page.

Compact columns are projected conditionally: a column is emitted only when the skill actually carries a value, so missing_dependencies and stage (and, outside compact mode, layer / runtime / runtime_state) are absent rather than empty for most rows. Clients must treat these as optional, not as guaranteed keys.

Walking the full catalogue:

python
skills, offset = [], 0
while True:
    page = call("list_skills", {"offset": offset})
    skills.extend(page["skills"])
    if not page["truncated"]:
        break
    offset = page["next_offset"]

When the caller only knows the intent and not the name, call search_skills(query=...) instead — it is ranked and returns the best matches on the first page, which costs far less context than walking list_skills.

Lazy-actions fast-path (opt-in) ​

Enabled via McpHttpConfig.lazy_actions = True. Replaces the per-tool tools/list expansion with three meta-tools so the tool surface stays small regardless of how many tools are registered:

ToolPurpose
list_actionsCompact {id, summary, tags} records for every enabled action, no schemas.
describe_actionFull input schema for one action by id.
call_actionGeneric dispatcher — invokes any action by id with args. Same code path as a direct tools/call.

Typical flow: list_actions(dcc="maya") → pick an id → describe_action(id=...) → call_action(id=..., args={...}).

Writing new built-in tools ​

Built-in tool descriptions are hand-written, not auto-generated. When adding a new built-in, follow the 3-layer pattern used by every entry in build_core_tools_inner():

<1 sentence present-tense summary of what the tool does>

When to use: <1-2 sentences naming the situation, and explicitly contrasting the tool against its nearest sibling so an agent knows when NOT to pick it>

How to use:
- <precondition or common pitfall>
- <suggested follow-up tool, fully qualified>

Keep the whole string ≤ 500 chars; move any longer reference material here, under a stable heading, and link to it from the description. Per-parameter description fields in the input schema are single clauses ≤ 100 chars. tests/test_tool_descriptions.py enforces both bounds structurally so future tools stay consistent.

Full Example: Maya MCP Server ​

python
from dcc_mcp_core import ToolRegistry, McpHttpServer, McpHttpConfig

# Build tool registry
registry = ToolRegistry()
registry.register(
    "get_scene_info",
    description="Get current Maya scene information",
    category="scene",
    tags=["query", "info"],
    dcc="maya",
    version="1.0.0",
    input_schema='{}',
)

def get_scene_info(params):
    # In practice, query Maya via pymel/cmdx
    return {"scene_name": "untitled", "object_count": 0}

server = McpHttpServer(registry, McpHttpConfig(
    port=18812,
    server_name="maya-mcp",
    server_version="1.0.0",
))
server.register_handler("get_scene_info", get_scene_info)

# Start HTTP server
handle = server.start()

print(f"Maya MCP server: {handle.mcp_url()}")
# Output: Maya MCP server: http://127.0.0.1:18812/mcp

Gateway ​

When multiple DCC instances start simultaneously, one automatically becomes the gateway — a single well-known /mcp endpoint and /v1/* REST facade for all running instances. Since v0.15, the gateway does not merge every backend action into tools/list; it keeps MCP bounded and advertises only four canonical workflow tools: search, describe, load_skill, and call.

How it works ​

  • Every instance registers itself in a shared FileRegistry (JSON file on disk) and sends periodic heartbeats.
  • The first process to bind gateway_port (default: 9765 for the Python API and dcc-mcp-server) becomes the gateway; all others are plain instances.
  • Mutual exclusion uses SO_REUSEADDR=false (via socket2), so the first-wins semantics are reliable across platforms including Windows.
  • The gateway probes GET /v1/readyz (falling back to /health for old backends) and evicts instances after consecutive failures.
  • When the process exits, McpServerHandle is dropped and the instance is automatically deregistered.

Gateway endpoints ​

EndpointMethodDescription
/instancesGETJSON list of all live instances
/v1/instancesGETREST alias for instance discovery
/healthGETHealth check with a payload-safe job_persistence state snapshot
/mcpPOSTBounded MCP endpoint with gateway workflow primitives (search, describe, load_skill, call)
/mcpGETSSE stream for progress, job/workflow, resource, and prompt notifications
/v1/searchPOSTSearch compact backend capability records
/v1/describePOSTFetch schema, annotations, and routing record for one tool_slug
/v1/callPOSTInvoke one backend capability by tool_slug
/v1/resourcesGETAggregate gateway-native and backend MCP resources
/v1/resources/{uri}GETRead one percent-encoded resource URI
/v1/promptsGETAggregate backend prompt templates
/v1/prompts/{name}GETRender one prompt; ?args=<json> forwards prompt arguments
/v1/jobs/{id}/eventsGETSSE stream for one async job
/v1/jobs/{id}DELETECancel one async job
/mcp/{instance_id}POSTTransparent proxy to a specific instance (low-level escape hatch)
/mcp/dcc/{dcc_type}POSTProxy to the best instance of a DCC type

Bounded facade ​

POST /mcp on the gateway is a single MCP server that advertises only four canonical gateway tools in tools/list:

TierToolsPurpose
Discoverysearch, describeSearch compact backend capability records and fetch schema/detail for one discovered tool_slug or skill_name
Activationload_skillLoad a discovered skill or activate/deactivate a progressive tool group
ExecutioncallInvoke one tool_slug or an ordered {calls:[...]} batch

Gateway backend actions are addressed by tool_slug (<dcc>.<id8>.<tool>). Direct per-DCC REST uses <dcc>.<skill>.<action> without the instance id. Agents should not construct slugs by hand; obtain them from MCP search or POST /v1/search, inspect with MCP describe or POST /v1/describe, then execute via MCP call, POST /v1/call, or POST /v1/call_batch. Hidden MCP compatibility routes still accept older search_tools / describe_tool / call_tool / call_tools names, but they are not advertised.

gateway://instances — DCC registry as an MCP resource (#813 phase 1) ​

The live DCC registry is published as a gateway-native MCP resource, not as a tool. Agents fetch it via resources/read instead of paying the tools/list token cost for instance-discovery verbs.

jsonc
// Request: list every parseable row in the registry directory
// (`$TEMP/dcc-mcp-registry/`), regardless of `dcc_type`. Stale sentinels
// surface with `status: "stale"` so operators can see why a registration
// is no longer routable instead of having it silently elided.
{"jsonrpc":"2.0","id":1,"method":"resources/read",
 "params":{"uri":"gateway://instances"}}

// Optional URI query: hide stale rows / show the raw registry view
// (default: stale visible, dead-PID rows pruned).
//   gateway://instances?include_stale=false
//   gateway://instances?include_dead=true

The payload returned in contents[0].text is a JSON document of shape:

json
{
  "total": 3,
  "stale_count": 1,
  "evicted_dead": 0,
  "instances": [
    {
      "instance_id": "a1b2c3d4-…",
      "dcc_type": "maya",
      "host": "127.0.0.1",
      "port": 18812,
      "mcp_url": "http://127.0.0.1:18812/mcp",
      "status": "available",
      "scene": "/proj/shot01.ma",
      "documents": [],
      "pid": 1234,
      "display_name": "Maya-Rigging",
      "version": "2024",
      "adapter_version": "0.3.0",
      "adapter_dcc": "maya",
      "metadata": {},
      "stale": false
    },
    {
      "instance_id": "f9e8d7c6-…",
      "dcc_type": "maya",
      "host": "127.0.0.1",
      "port": 18813,
      "status": "stale",
      "stale": true,
      "...": "fields above also present"
    }
  ]
}

Each entry already carries mcp_url, so a client that has read this resource has everything it needs to connect — no follow-up "connect" verb is required. To inspect a single instance, read gateway://instances/{instance_id} (full UUID or unique prefix).

The bookkeeping __gateway__ sentinel and the gateway's own self-row are always filtered.

Python example ​

python
from dcc_mcp_core import ToolRegistry, McpHttpServer, McpHttpConfig

registry = ToolRegistry()
registry.register("get_scene_info", description="Get scene info", category="scene", dcc="maya")

config = McpHttpConfig(port=0, server_name="maya-mcp")
# Python default: gateway_port=9765, admin_enabled=True, admin_path="/admin".
# Set gateway_port=0 to disable gateway/admin, or admin_enabled=False to keep gateway only.
config.dcc_type = "maya"
config.dcc_version = "2025"
config.scene = "/proj/shot01.ma"  # optional: helps routing by scene

server = McpHttpServer(registry, config)
handle = server.start()

print(handle.is_gateway)        # True if this process won the gateway port
print(handle.mcp_url())         # direct MCP URL for this instance
# → gateway at http://127.0.0.1:9765/ (if is_gateway=True)
# → instance at http://127.0.0.1:<port>/mcp

Multiple DCCs, one endpoint

Start any number of DCC servers — the first one wins the gateway port. Agents connect to http://localhost:9765/mcp, call search to discover backend capabilities, use describe for schema inspection, then execute with POST /v1/call. Reading the gateway://instances MCP resource yields each backend's mcp_url directly when an agent wants a direct, un-proxied session.

Skills-First + gateway

create_skill_server() uses the provided McpHttpConfig. A freshly constructed Python McpHttpConfig joins gateway election by default (gateway_port=9765) and exposes Admin on the elected gateway. Set gateway_port=0 for an isolated server:

python
import os
from dcc_mcp_core import create_skill_server, McpHttpConfig

config = McpHttpConfig(port=0, server_name="maya")
# config.gateway_port = 0        # uncomment to disable gateway/admin
# config.admin_enabled = False   # keep gateway, hide Admin UI
config.dcc_type = "maya"

server = create_skill_server("maya", config)
handle = server.start()

Job admission and compatibility ​

For registered tools on the job-capable REST/MCP paths, Core creates a background job when the tool declares execution: async, the caller sets dcc.async=true, or the caller supplies a valid progress token. timeout_hint_secs is only a latency/timeout budget hint; it does not change execution mode. Legacy Core accepts string/number progress tokens; null, boolean, array, and object tokens do not opt in. Use strings for portable clients; modern ingress applies its own stricter validation.

This token-to-job rule is a Core policy, not a general MCP requirement. Use params._meta for native MCP requests and meta for REST wrappers. REST's existing progress_token alias is not an MCP field; a present canonical progressToken masks that REST alias even when null. MCP's existing arguments._meta fallback retains its separate null/async-OR merge behavior.

Upgrade boundary

The async-admission fix restores MCP metadata moved into the SDK request context. Clients that already attach a valid progress token may now receive a Core job receipt instead of a direct result. Older REST builds may also turn timeout-only synchronous calls into jobs. Verify the installed wheel before assuming either behavior.

When a receipt contains job_id, query that same job with jobs_get_status until terminal and read its result. Do not resend the original operation or switch transports to replay it because SSE is absent. Core job completion and adapter-owned background operation completion remain separate contracts.

Job lifecycle notifications ​

Core background jobs are tracked by a JobManager. Transports with a bound job publisher and subscribed session can expose the following SSE channels (issue #326); not every synchronous call creates a job, and a job receipt alone does not establish notification delivery:

ChannelMethodFires when
Anotifications/progressA valid _meta.progressToken is bound to the job publisher. Core maps pending=0, running=10, terminal states=100.
Bnotifications/$/dcc.jobUpdatedenable_job_notifications is True (default). One event per transition, payload carries job_id, tool, status, started_at, completed_at, error.
Cnotifications/$/dcc.workflowUpdatedSame flag; emitted by the workflow executor (#348) on step enter / step terminal / workflow terminal.
python
cfg = McpHttpConfig(port=8765)
# Default True; set False to opt the whole server out of the $/dcc.* channels.
cfg.enable_job_notifications = True

MCP progress tracking is optional, including when a token is supplied. The flag controls Core channels B and C, not whether a transport has a usable notification binding. Restoring native rmcp request-context metadata does not by itself add an SSE subscription or guarantee job notifications. Polling below remains the terminal-result path.

Built-in tools: jobs_get_status ​

Clients that can't consume the $/dcc.jobUpdated SSE stream (or simply prefer request/response) can poll job state via the always-registered jobs_get_status built-in tool (issue #319).

  • Name: jobs_get_status — client-safe (validated with TOOL_NAME_RE at server startup; the build panics if the regex ever rejects the name).
  • Visibility: surfaced in tools/list unconditionally, regardless of which skills are loaded or whether any jobs exist.
  • Gateway routing: remains callable while host/DCC readiness is temporarily red during a reload; ordinary DCC tools still require a ready backend.
  • Annotations: readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=false.

Input schema:

FieldTypeDefaultDescription
job_idstring— (required)UUID of the job to query
include_logsbooleanfalseForward-compat flag; JobManager does not currently capture stdout/stderr, so the flag is a no-op and a tracing::debug! breadcrumb is emitted
include_resultbooleantrueWhen true and the job is terminal (completed / failed), include the final ToolResult JSON under result; otherwise the key is omitted

Success envelope (returned inside a CallToolResult — text content mirrors structuredContent):

json
{
  "job_id": "c8aa…",
  "parent_job_id": null,
  "tool": "scene.get_info",
  "status": "completed",
  "created_at": "2026-04-22T10:00:00+00:00",
  "started_at": "2026-04-22T10:00:00+00:00",
  "completed_at": "2026-04-22T10:00:01+00:00",
  "updated_at": "2026-04-22T10:00:01+00:00",
  "progress": {"current": 10, "total": 10, "message": "done"},
  "error": null,
  "result": { "...": "…final ToolResult…" }
}

Field semantics mirror the $/dcc.jobUpdated channel so polling and streaming clients observe the same shape. started_at is derived from updated_at once the job leaves pending; completed_at from updated_at once it reaches a terminal state.

Unknown job id → CallToolResult { isError: true, content: [{type:"text", text:"No job found with id '<bad>'"}] }. This is always an MCP tool-level error, never a JSON-RPC transport error — the response still carries a successful result field with isError=true. An unknown id never mints a new job.

Disk-backed counters (issue #2262). When a job declares where its outputs land (output_dir, optionally output_extensions), progress is reconciled against the files actually on disk at read time: a handler that caches its counter cannot report 0 while frames are already written. The reconciled payload carries counter_source (disk or reported) and, when the two disagree, reported_current. total is never left below current.

The output directory is read from the job's result once it reaches a terminal state, and — so that a running job is covered too, which is the shape issue #2262 describes — from the output directory declared in its launch arguments while it is still running. A job that declares none keeps its reported counter, and counter_source says so.

Python example:

python
body = post_mcp({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
                 "params": {"name": "jobs_get_status",
                            "arguments": {"job_id": jid}}})
env = body["result"]["structuredContent"]
if env["status"] in {"completed", "failed", "cancelled", "interrupted"}:
    print("final:", env.get("result"))

Built-in tools: jobs_cleanup ​

Prune terminal jobs from JobManager (and any attached JobStorage backend) once they age out. Complements jobs_get_status and the optional SQLite persistence backend (see docs/guide/job-persistence.md, issue #328).

  • Name: jobs_cleanup — client-safe, validated at server startup.
  • Visibility: always surfaced in tools/list, independent of which skills are loaded.
  • Annotations: readOnlyHint=false, destructiveHint=true, idempotentHint=true, openWorldHint=false.

Input schema:

FieldTypeDefaultDescription
older_than_hoursinteger ≥ 024Remove terminal jobs whose last updated_at is older than this many hours. Set to 0 to purge all terminal rows.

Only terminal statuses (completed, failed, cancelled, interrupted) are eligible — pending and running rows are never removed regardless of age.

Success envelope (CallToolResult.structuredContent):

json
{ "removed": 42, "older_than_hours": 24 }

Built-in tools: jobs_poll_contract ​

Report how an async job type is polled to a terminal state by the unified poller (issue #2262). This is the diagnostic for "--wait cannot work on this tool": an unregistered job type answers registered: false with a reason instead of letting the wait return early.

  • Name: jobs_poll_contract — client-safe, validated at server startup.
  • Visibility: always surfaced in tools/list, independent of which skills are loaded.
  • Annotations: readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=false.

Input schema:

FieldTypeDefaultDescription
job_typestring— (optional)Tool that starts the job, e.g. houdini_render__render_rop. Omit to list every registered job type.

A job type is registered when its result declares a poll descriptor ({owner, tool, arguments}) or when the adapter registers the contract directly. A descriptor is only usable when its arguments object identifies exactly one job with exactly one field — the field name may be anything (job_id, render_id, …), but a second field is rejected rather than registered, because a contract carries one argument and replaying it would drop the rest and poll the tool with a missing required input.

Envelopes:

json
{
  "registered": true,
  "job_type": "houdini_render__render_rop",
  "poll": {"owner": "adapter", "tool": "houdini_render__get_render_job", "argument_field": "job_id"},
  "output": {"dir": "/tmp/renders", "extensions": ["exr"]}
}
json
{
  "registered": false,
  "job_type": "blender_render__render_sequence",
  "reason": "job_type_not_registered",
  "hint": "Register the job type with the unified poller (declare a poll descriptor on the launching tool) so --wait can reach a terminal state."
}

Optional SQLite job persistence ​

Set McpHttpConfig.job_storage_path to persist JobManager state so in-flight jobs survive a restart (issue #328). Requires the job-persist-sqlite Cargo feature; when the feature is absent but the path is set, McpHttpServer.start() returns a descriptive error rather than silently falling back to the in-memory store.

python
cfg = McpHttpConfig(port=8765)
cfg.job_storage_path = "/var/lib/dcc-mcp/jobs.sqlite3"
cfg.job_recovery = "drop"        # default; "requeue" reserved (issue #567)
cfg.job_retention_hours = 24 * 30 # optional startup pruning of terminal rows only

job_retention_hours is unset by default. When configured, startup removes only terminal jobs older than the cutoff; non-terminal rows are never selected. A retention failure leaves persisted rows unchanged and is reported through the job-persistence health status. SQLite storage also takes an exclusive <database>.lock ownership lease, so a second process fails closed instead of sharing the database silently.

On startup, any pending / running rows from a previous run are rewritten to the new terminal interrupted status with error = "server restart" and surfaced via $/dcc.jobUpdated.

McpHttpConfig.job_recovery selects the policy applied to those rows (issue #567):

ValueBehaviour
"drop"Default. Rows are flipped to interrupted; clients re-running the work must re-submit explicitly.
"requeue"Reserved. Accepted today but degrades to "drop" with a WARN log (requested_policy=requeue effective_policy=drop). True requeue requires persisting the original tool arguments alongside the row, which lands in a future release. Setting this today is forward-compatible: the same config will pick up real requeue when it ships.

Full design and operational guidance: docs/guide/job-persistence.md.

CORS Configuration ​

Enable CORS for browser-based MCP clients:

python
cfg = McpHttpConfig(port=8765, enable_cors=True)
server = McpHttpServer(registry, cfg)
handle = server.start()
print(handle.mcp_url())

Error Handling ​

The server returns JSON-RPC error responses:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params: missing 'radius'",
    "data": null
  }
}

Common error codes:

CodeMeaning
-32600Invalid Request
-32602Invalid Params
-32603Internal Error
-32000Tool not found
-32001Tool validation failed
-32002Tool handler error

Context-Efficient Tool Loading (issue #405) ​

When a skill catalog grows large (50+ tools × multiple skills), the initial tools/list response becomes expensive in token terms. Three strategies let you control how much of the schema surface is pushed into the agent's context.

Comparison ​

Strategytools/list contentToken costBest for
Default (eager)All tool definitions + schemasHigh≤ 20 tools
lazy_actions = True3 meta-tools onlyVery lowLarge static catalogs
Skill stubs (default)__skill__<name> stubs for unloaded skillsLowDynamic loading workflows
search (gateway) / search_tools (direct server)Compact search results; fetch schema with gateway describe or direct-server get_skill_infoLowLarge catalogs and multi-DCC gateways

1. Default: Skill Stubs ​

Without any config, tools/list emits:

  • A __skill__<name> stub for every unloaded skill (name + 1-line description, no input schemas)
  • Full tool definitions for every loaded skill

Agent workflow: search_tools(query) or search_skills(query) → get_skill_info(skill_name=...) for the selected skill → load_skill(name) → call the typed tool. If a client needs to inspect tools/list, it must follow every nextCursor; the first page is not a complete index.

python
from dcc_mcp_core import create_skill_server, McpHttpConfig

server = create_skill_server("maya", McpHttpConfig(port=8765))
handle = server.start()
# tools/list returns __skill__<name> stubs for each discovered skill
# Until load_skill() is called, no input schemas are sent to the agent

2. lazy_actions = True (3 meta-tools) ​

Surfaces only list_actions, describe_action, call_action.

python
cfg = McpHttpConfig(port=8765)
cfg.lazy_actions = True
server = create_skill_server("maya", cfg)

Token footprint for tools/list is essentially constant (3 tool entries) regardless of catalog size. The agent must call list_actions(dcc="maya") first to discover available tools.

3. lazy_tool_schemas = True (planned, issue #405) ​

Note: lazy_tool_schemas is planned for a future release. Track issue #405.

When enabled, tools/list will return tool entries with description only, omitting inputSchema. A subsequent tools/describe call returns the full schema for a single tool. This aligns with the pattern Anthropic describes as cutting tool-definition tokens by 85%+ while maintaining high selection accuracy.

python
# Planned API (not yet available):
cfg = McpHttpConfig(port=8765)
cfg.lazy_tool_schemas = True  # omit inputSchema from tools/list (planned)

Direct per-DCC servers expose search_tools for active tools and unloaded skill candidates. Use get_skill_info(skill_name=...) to inspect the selected skill's full declared tool schemas before load_skill, then call the concrete tool by name. tools/list remains MCP-compatible and paginated, but production agents should not treat its first page as a complete discovery index.

search_tools supports natural-language multi-word phrase matching: each word (≥2 chars) in a phrase like "create sphere" or "rig framework" must appear as a substring in the tool name or description, so underscore-separated names like create_sphere are found without requiring the user to guess the underscore convention.

The gateway advertises the canonical MCP workflow search → describe → load_skill (when needed) → call. REST /v1/search, /v1/describe, /v1/load_skill, /v1/call, and /v1/call_batch remain the pure HTTP twin. Gateway search returns compact records only; describe fetches one full schema on demand, keeping tools/list bounded even when many backends are live. The gateway REST workflow returns compact TOON by default; clients that require legacy JSON should send Accept: application/json or body response_format: "json".

Token Usage Decision Guide ​

Catalog size  ┌─────────────────────────────────────────────────────┐
< 20 tools    │ Default (eager) — simplest, no configuration needed  │
20-100 tools  │ Skill stubs (default) — search_skills + load_skill   │
100+ tools    │ lazy_actions=True  — constant 3 meta-tools           │
> 500 tools   │ lazy_tool_schemas=True (planned) — 85%+ reduction    │
              └─────────────────────────────────────────────────────┘

Connection-Scoped Tool Cache (issue #438) ​

When an MCP agent loop calls tools/list repeatedly between sequential tool calls (e.g. "create sphere → assign material → add light → render"), each call rebuilds the full tool list from scratch — scanning the ToolRegistry, resolving bare names, converting every ToolMeta into an McpTool, and fetching unloaded skill stubs. With 100+ registered tools, this per-request overhead becomes measurable.

The connection-scoped cache stores a per-session snapshot of the tools/list result. On subsequent calls, if the registry has not changed, the cached snapshot is returned directly — skipping all redundant computation.

How it works ​

  1. On the first tools/list call in a session, the full tool list is built normally and stored as a ToolListSnapshot on the McpSession.
  2. On subsequent calls, the server checks the current registry generation against the snapshot's generation:
    • Match → return the cached snapshot (fast path)
    • Mismatch → rebuild and store a fresh snapshot (slow path)
  3. Cursor pagination is applied on the cached snapshot just like on a fresh list.

Cache invalidation ​

The cache is automatically invalidated when any of these events occur:

EventMechanism
Skill loaded (load_skill)Registry generation bumped; all session caches cleared
Skill unloaded (unload_skill)Registry generation bumped; all session caches cleared
Tool group activated/deactivatedRegistry generation bumped; all session caches cleared
Session evicted (TTL expiry)Session and its cache are dropped
Client sends _meta.dcc.refresh = trueCache bypassed for that single request

Configuration ​

python
from dcc_mcp_core import McpHttpConfig

cfg = McpHttpConfig(port=8765)
# Tool cache is enabled by default (True). Disable to force a full
# rebuild on every tools/list call (useful for debugging).
cfg.enable_tool_cache = False

Forcing a refresh ​

MCP clients can request a fresh tool list by including _meta.dcc.refresh = true in the tools/list request:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {},
  "_meta": { "dcc": { "refresh": true } }
}

Expected impact ​

MetricBeforeAfter (cache hit)
tools/list response time (100+ tools)Full registry scan + bare-name resolution + McpTool constructionSnapshot clone + pagination (~0ms resolution overhead)
Agent loop throughputN independent requestsSession-aware sequential optimization

Gateway behavior ​

The cache is per-session on the individual DCC instance. Gateway requests are proxied to the backend instance, so the cache operates transparently — the gateway itself does not cache tool lists.

Performance Notes ​

  • Server runs in background Tokio thread — no DCC main thread blocking
  • Request timeout applies per-call (default 30s)
  • No connection pooling on the HTTP layer (each POST is stateless)
  • Use IpcChannelAdapter for persistent IPC sessions with DCC
  • Gateway FileRegistry flushes to disk on every mutation — safe for multi-process but not high-frequency writes

Released under the MIT License.