Per-DCC REST Skill API
The MCP gateway is not the only way to make a DCC's skills callable from outside. Each McpHttpServer embedded in a DCC process can also expose its discovered skills as a small, versioned REST surface mounted at /v1/*. The gateway then indexes and routes to those per-DCC services rather than republishing every backend action as a separate MCP tool.
Why
| Problem with gateway-only exposure | How the per-DCC REST surface fixes it |
|---|---|
tools/list grows linearly with instances × actions | REST stays bounded: discovery, describe/call, resources, prompts, jobs |
MCP tool names must match ^[A-Za-z0-9_-]{1,64}$ | REST slugs use <dcc>.<skill>.<action> on per-DCC servers |
| Non-MCP agents need a separate adapter to call DCC code | They POST /v1/call with a JSON body |
| MCP resources/prompts need JSON-RPC clients | They can use GET /v1/resources* and GET /v1/prompts* |
| No structured error class for "skill not loaded" | ServiceErrorKind::SkillNotLoaded (kebab-case) |
Routes
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/healthz | Liveness |
| GET | /v1/readyz | Runtime readiness bits (process/dcc/skill_catalog/dispatcher/host_execution_bridge/main_thread_executor) |
| GET | /v1/openapi.json | utoipa-generated OpenAPI 3.x contract |
| GET | /docs | Optional Scalar UI for the OpenAPI contract (DCC_MCP_DOCS_UI=0 disables it) |
| GET | /v1/skills | Loaded skills/actions |
| POST | /v1/search | Compact keyword/tag/dcc/scope search |
| POST | /v1/describe | Schema + annotations for one slug |
| GET | /v1/tools/{slug} | Alias for describe |
| POST | /v1/call | Invoke one tool by slug; accepts params and arguments |
| GET | /v1/context | Current DCC scene/document snapshot |
| GET | /v1/resources | MCP-style resource list |
| GET | /v1/resources/{uri} | Read one percent-encoded resource URI |
| GET | /v1/resources/{uri}/events | SSE stream for one resource's updates |
| GET | /v1/prompts | MCP-style prompt template list |
| GET | /v1/prompts/{name} | Render one prompt; pass JSON object args as ?args=... |
| GET | /v1/jobs/{id}/events | SSE stream for one async job |
| DELETE | /v1/jobs/{id} | Cancel one async job |
SOLID layering
SkillRestRouter ← axum thin adapter
│
SkillRestService ← pure logic, no axum
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
SkillCatalogSource ToolInvoker ResourceProvider PromptProvider JobProvider AuthGate AuditSink
(trait) (trait) (trait) (trait) (trait) (trait) (trait)Each collaborator is a trait so adapters (Maya/Blender/Houdini) can swap in their own implementation without touching the router. Defaults wire to:
SkillCatalog(dcc-mcp-skills) for the catalog source,ToolDispatcher(dcc-mcp-actions) for invocation,- empty
ResourceProvider/PromptProviderdefaults that adapters can replace, JobProviderfor job event streams and cancellation,AllowLocalhostGatefor auth (loopback-only),NoopAuditSinkfor audit.
Token efficiency
/v1/search hits intentionally omit input_schema. A regression test asserts each serialised SkillListEntry stays under SEARCH_HIT_BUDGET_BYTES (currently 512 bytes), so an agent can page hundreds of capabilities per turn without blowing its context budget. Schema is fetched on demand by POST /v1/describe with include_schema: true (default).
Enterprise controls (#660)
- Versioned paths —
/v1/*is the stable contract. - Structured errors — single envelope
{kind, message, hint, request_id, candidates?},kindis kebab-case (unknown-slug,ambiguous,skill-not-loaded,invalid-params,unauthorized,bad-request,affinity-violation,not-ready,host-busy,backend-error,internal). - Auth gate — pluggable
AuthGate. DefaultAllowLocalhostGaterejects non-loopback peers. Enable remote calls by installingBearerTokenGate::new(vec![token])and binding the listener to a non-loopback interface. - Audit sink — every call emits one
AuditEvent({request_id, at, slug, route, subject, outcome, duration_ms}). - Readiness bits —
process / dcc / skill_catalog / dispatchergate normal routing;host_execution_bridge / main_thread_executorare exposed separately so main-thread smoke tests can require them before calling DCC tools./v1/callreturns503 not-readyuntil the base routing bits are green. - OpenAPI — generated by
utoipafrom theToSchemaderives on the request/response types — no hand-maintained JSON.
Wiring example
use std::sync::Arc;
use axum::Router;
use dcc_mcp_actions::{ToolDispatcher, ToolRegistry};
use dcc_mcp_skill_rest::{
AllowLocalhostGate, BearerTokenGate, NoopAuditSink, SkillRestConfig,
SkillRestService, StaticReadiness, build_skill_rest_router,
};
use dcc_mcp_skills::SkillCatalog;
fn build_dcc_app(
registry: Arc<ToolRegistry>,
dispatcher: Arc<ToolDispatcher>,
) -> Router {
let catalog = Arc::new(SkillCatalog::new_with_dispatcher(
registry.clone(),
dispatcher.clone(),
));
let service = SkillRestService::from_catalog_and_dispatcher(catalog, dispatcher);
let cfg = SkillRestConfig::new(service)
.with_auth(Arc::new(AllowLocalhostGate::new()))
.with_audit(Arc::new(NoopAuditSink))
.with_readiness(Arc::new(StaticReadiness::fully_ready()));
build_skill_rest_router(cfg)
}Calling pattern
# 1. Search compact hits.
curl -s localhost:8765/v1/search -d '{"query":"sphere"}' -H 'content-type: application/json'
# 2. Fetch the schema for one slug.
curl -s localhost:8765/v1/describe \
-d '{"tool_slug":"maya.spheres.create_sphere","include_schema":true}' \
-H 'content-type: application/json'
# 3. Invoke. `arguments` is accepted as an alias for `params`.
curl -s localhost:8765/v1/call \
-d '{"tool_slug":"maya.spheres.create_sphere","arguments":{"radius":1.5}}' \
-H 'content-type: application/json'
# 4. Read MCP resources / render prompts without JSON-RPC.
curl -s localhost:8765/v1/resources
curl -s 'localhost:8765/v1/resources/scene%3A%2F%2Fcurrent'
curl -s 'localhost:8765/v1/prompts/create_plan?args=%7B%22task%22%3A%22model%20a%20prop%22%7D'