MCP Admin & Consumer Servers
Operate a tenant or administer Ductor's product lifecycle through isolated, stateless MCP 2026-07-28 control planes.
Ductor includes two inbound Model Context Protocol (MCP) servers. External AI clients can discover and call typed tools without receiving broader access to the REST API. This is one way an agent becomes a Worker that operates the clearing layer: the same tools a person reaches through the REST API, offered to an agent through a governed, typed surface — shipping today, and the same auth chain either way.
| Control plane | Default route | Identity boundary | Purpose |
|---|---|---|---|
| Consumer | /mcp | Authenticated, active tenant | Operate the products and workflows that tenant policy exposes. |
| Admin | /mcp/admin | Authenticated platform-admin | Provision and govern tenants, members, roles, invites, and tenant API keys. |
The routes have different registries, server identities, and authorization chains. Admin tools never appear on the consumer route, and the admin route does not inherit a consumer tenant.
Ductor implements MCP 2026-07-28, sometimes described as the stateless MCP
core. Each request contains everything the protocol needs. Ductor does not run
an initialization exchange, issue Mcp-Session-Id, or require a client to stay
connected to one replica.
Protocol statelessness does not remove durable application state
Tool authorization leases, manifests, chat turns, approvals, jobs, and audit records can still be durable. They are explicit Ductor resources, not hidden MCP transport sessions. This makes retries and recovery observable while the HTTP transport remains stateless.
Enable the control planes
Both planes are off by default and mount on the existing API server. Enable the consumer plane first; configuration validation rejects an admin plane enabled on its own.
mcp:
enabled: true
path: /mcp
admin_enabled: true
admin_path: /mcp/admin
expose_unwired_tools: false
cors:
allowed_origins: "https://console.example.com"mcp.enabledmounts the endpoint when set totrue.mcp.pathsets the mount path. The default is/mcp.mcp.admin_enabledmounts the platform-admin endpoint when set totrue.mcp.admin_pathsets its distinct mount path. The default is/mcp/admin.mcp.expose_unwired_toolscontrols whether discovery includes tools without a live backend. Keep itfalsein production.cors.allowed_originscontrols browser origins accepted by the MCP endpoint. Enterprise mode requires an explicit list and rejects*.
The equivalent environment overrides are DUCTOR_MCP_ENABLED,
DUCTOR_MCP_PATH, DUCTOR_MCP_ADMIN_ENABLED, and
DUCTOR_MCP_ADMIN_PATH. Enterprise mode also requires an audit sink before it
will enable admin MCP.
Consumer plane
The gateway derives the tenant from the authenticated principal, confirms that
the tenant is active, authorizes MCP access, and applies the tenant's tool
exposure manifest. A client cannot select another tenant in tool arguments or
replace its identity with an X-Tenant-ID header.
The consumer catalog is assembled from the services wired into the running Ductor process. It can cover workflow and routing operations, connectors, workspaces, environments, governance, usage, reliability, cases, and work assignments. Connector actions and triggers appear only when their catalog and execution boundaries are available.
Call consumer.readiness to see live counts by tool family and mutation class.
This reports whether a family is wired; tools/list remains authoritative for
the tools this particular identity may discover. Write and destructive tools
still require their manifest grants, matching schema hashes, and configured
safeguards.
Admin plane
Before MCP dispatch, /mcp/admin requires the dedicated platform-admin role and a
successful authorization decision for the fixed platform_admin resource.
Every tenant-scoped tool then requires a valid, explicit tenant_id.
The tenant admin role is not sufficient for this endpoint. Tenant API keys cannot
mint platform-admin; use a separately provisioned platform machine identity. This
keeps a compromised tenant credential from crossing into product-lifecycle
administration even when that credential legitimately administers its own tenant.
The admin registry contains 19 tools:
| Domain | Read operations | Mutations |
|---|---|---|
| Readiness | admin.readiness | — |
| Tenants | admin.tenant.list, admin.tenant.get | admin.tenant.create, admin.tenant.update, admin.tenant.delete |
| Members | admin.member.list, get, roles, scopes | assign_role, invite, revoke_invite, disable, reenable, remove |
| API keys | admin.api_key.list | admin.api_key.create, admin.api_key.revoke |
Member operations in the table retain the admin.member. prefix. Call
admin.readiness to inspect the live tenant, member, API-key, and audit
dependencies. If a service is absent, its tool family is not advertised.
Tenant deletion is retained soft deletion: the record remains with status
deleted. MCP does not provide a hard-delete shortcut around retention or
audit policy.
Destructive confirmation
Destructive calls require a target-specific confirmation string. A generic
approval cannot be replayed against another object.
| Target | Required confirmation |
|---|---|
| Tenant | <tenant_id> |
| Member | <tenant_id>/<member_id> |
| Invite | <tenant_id>/<invite_id> |
| API key | <tenant_id>/<key_id> |
Role replacement uses the member form because it can remove existing authority.
Create a tenant API key
admin.api_key.create uses a stateless, two-round approval:
- The first call validates the tenant, name, roles, scopes, environment restrictions, and optional expiry.
- Ductor returns
input_required, anelicitation/createrequest, and opaque state bound to those arguments. - The client collects typed approval and retries the same call with unchanged
arguments, matching
requestState, andinputResponses. - Ductor persists the hashed credential and returns plaintext exactly once.
The result is marked io.ductor/sensitive: true and
io.ductor/retention: none. Deliver it directly to an approved secret store;
do not put it into model context, chat history, logs, traces, certification
transcripts, or retry payloads. List operations return only redacted records
and prefixes.
Admin audit trail
Every successful admin mutation emits an mcp_admin audit event when an audit
sink is configured. The event records the authenticated subject, action,
tenant, target identifier, decision, timestamp, and trace ID. Arguments are not
copied into the event, so one-time credentials and invite content do not enter
the admin audit record.
Enterprise mode refuses to enable admin MCP without the sink. Strict auditing also rejects a mutation before storage changes if the required sink is unavailable.
Supported methods
The server exposes a tools-only MCP surface:
| Method | Purpose | Identity context |
|---|---|---|
server/discover | Returns supported versions, capabilities, instructions, identity, and cache hints. | Authenticated tenant or platform admin |
tools/list | Returns the deterministic catalog visible to this caller. | Authenticated tenant or platform admin |
tools/call | Re-authorizes and invokes one named tool. | Authenticated tenant or platform admin |
initialize and ping are not part of this version. An unknown method returns
HTTP 404 with JSON-RPC code -32601. Ductor accepts valid notifications with
HTTP 202 and no response body.
The current implementation does not advertise prompts, resources, subscriptions, tasks, or server-initiated event streams.
Make a request
Every request must include the protocol version and client capabilities in both the HTTP and JSON-RPC contract. Empty client capabilities are valid; omitting the field is not.
curl https://api.example.com/mcp \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
--data '{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "my-client",
"version": "1.0.0"
}
}
}
}'Use https://api.example.com/mcp/admin with a dedicated platform-admin
credential for the admin plane. Do not reuse that credential in consumer
agents.
For tools/call, also send Mcp-Name with the tool name. Ductor verifies the
version, method, and name headers against the body before it looks up or runs a
tool. Trace context can be sent as traceparent, tracestate, and baggage
inside _meta.
Every successful response includes:
resultType, eithercompleteorinput_required;_meta["io.modelcontextprotocol/serverInfo"]; and- method-specific content.
Discovery results include public cache hints. Tool catalogs are private and currently use a zero TTL because visibility can change with tenant policy.
Sessions, manifests, and pins on every call
The consumer plane is governed by the
tool exposure engine: tools/list does more
than list. It builds a per-caller exposure manifest, mints a tool session
lease, and returns both alongside the catalog:
{
"tools": [ { "name": "consumer.readiness", "inputSchema": {...},
"_meta": { "io.ductor/tool_version": "v1",
"io.ductor/schema_hash": "9933...9caa",
"io.ductor/exposure_mode": "allow" } } ],
"agent_tool_session_id": "ats_01M47...",
"manifest_id": "atm_01M47...",
"manifest_hash": "2eb4...d043",
"receipt_id": "atr_01M47..."
}Every tools/call must present that lease and echo the tool's pins, as
top-level params fields next to name and arguments:
{
"name": "consumer.readiness",
"arguments": {},
"environment_id": "<same environment the list ran in, or omit for default>",
"agent_tool_session_id": "ats_01M47...",
"manifest_id": "atm_01M47...",
"manifest_hash": "2eb4...d043",
"expected_schema_hash": "9933...9caa",
"tool_version": "v1",
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {} }
}A call without the lease is denied with tool session id is required; a lease
minted in one environment is not found in another; a schema hash that does
not match the manifest is a drift refusal. Leases expire after 15 minutes —
run tools/list again to refresh. Every decision, allowed or denied, is
persisted as an exposure receipt you can read back with
GET /api/v2/agent-tool-exposure/receipts, which is the fastest way to see
exactly what the server received.
Stock MCP clients cannot send these fields
Claude Desktop, Cursor, and the reference SDKs send name and arguments
only. Put a thin client in front of Ductor that performs tools/list, keeps
the lease, and injects the pins on each call, or use the governed chat agent,
which does this for you.
Discover only the tools for a task
Supply optional tool_names in tools/list to request an exact subset before
the server builds the governed manifest. For a buyer observing an RFQ award
and verifying its original receipt, request these three tools:
{
"environment_id": "prod",
"tool_names": [
"api_agentrfqservice_getagentrfqobservation",
"work_receipt.get",
"work_receipt.verify"
],
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}Use the same version and method headers as other requests, with
Mcp-Method: tools/list. The selector accepts 1–64 raw entries, each an
exact nonblank tool name of at most 256 bytes. The server sorts and
deduplicates names; duplicates still count toward the raw-entry limit.
Supplied null, empty arrays, wrong types, padded names, oversized entries,
and unknown or unwired tools return JSON-RPC invalid params before a manifest
is created. Unwired selections are refused even when diagnostic discovery
allows unwired descriptors. An invalid selector never falls back to the full
catalog.
The selector narrows candidates; tenant policy then decides which of those tools are visible and callable. It grants no new permissions. Keep the returned session, manifest id/hash, receipt id, and each tool's schema/version pins, and send the matching pins on every call. A tool omitted from the governed manifest cannot execute under that lease. Discovery without a configured exposure engine narrows descriptors only and creates no authorization lease.
Omitting tool_names retains full discovery, including projected connector
actions (cap_action__<provider>__<action>) permitted by tenant policy. A full
installation can produce a large catalog and manifest; the exact task selector
keeps unrelated descriptors out of model context and manifest candidates.
Tenant exposure policy remains the authorization boundary, and
expose_unwired_tools should remain off in production.
Observe bidder allowances and job history
A worker owner can discover the two scalar-only bidder read tools with an exact selector:
{
"environment_id": "prod",
"tool_names": [
"api_agentrfqservice_listagentrfqbiddertargets",
"api_agentrfqservice_getagentrfqbidderobservation"
],
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}Both require agent_rfq:read from the actual authenticated caller, environment
scope, and current worker ownership. The original stored principal also scopes
the rows before pagination. Discovery narrows candidates without creating new
permissions. Use the returned session, manifest, and each descriptor's exact
schema/version pins on every call.
Call api_agentrfqservice_listagentrfqbiddertargets with worker_id, optional
page_size (default 20, max 64), and optional page_token. Read page.targets
and continue using page.next_page_token. Then inspect a returned target:
{
"name": "api_agentrfqservice_getagentrfqbidderobservation",
"arguments": {
"worker_id": "<owned worker id>",
"target_id": "<returned target id>",
"job_page_size": 20
},
"environment_id": "prod",
"agent_tool_session_id": "<returned session id>",
"manifest_id": "<returned manifest id>",
"manifest_hash": "<returned manifest hash>",
"expected_schema_hash": "<this tool's returned schema hash>",
"tool_version": "<this tool's returned version>",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}Send Mcp-Method: tools/call and
Mcp-Name: api_agentrfqservice_getagentrfqbidderobservation, with the same
protocol-version header as discovery. Tenant and environment are injected into
the generated REST request. Supply environment only in top-level params;
environment_id inside arguments is rejected by the tool schema.
Read observation.target and observation.jobs; continue with
observation.next_job_page_token as the next call's arguments.job_page_token.
An empty next token ends the traversal. Cursors are opaque, bounded, and scoped;
a foreign or invalid token is refused. Each page is its own snapshot.
With the RFQ front door enabled, persisted records remain readable while the bidder is disabled or the worker is paused. These reads do not authenticate the configured bidder secret, admit pricing, submit a bid, or renew a lease. Allowance counters describe full request ceilings reserved, not actual calls or money. A persisted target does not establish current configuration or runner health. Saved job state is unsigned operational status, not independent market or signed-settlement proof. Follow the HTTP and field reference for lifecycle, safe reasons, deadlines, and scalar privacy boundaries.
Tool parameter headers
A tool schema can mark a string, number, integer, or boolean input with
x-mcp-header:
{
"type": "object",
"properties": {
"region": {
"type": "string",
"x-mcp-header": "Region"
}
}
}The client sends the same value in arguments.region and
Mcp-Param-Region. Ductor rejects missing required, malformed, or conflicting
recognized parameter headers before execution. Values that are unsafe as plain
HTTP field values use MCP's Base64 sentinel encoding.
Ductor does not advertise a tool whose input schema has a non-object root or an
invalid x-mcp-header annotation. This prevents a client from discovering a
tool it cannot call correctly.
Request more input without a session
A tool can pause for typed client work by returning input_required:
{
"resultType": "input_required",
"inputRequests": {
"approval": {
"method": "elicitation/create",
"params": {
"message": "Approve this operation?"
}
}
},
"requestState": "opaque-tool-state"
}The client completes the requested operation, then retries the original
tools/call with inputResponses and the unchanged requestState. Ductor
passes both values to the tool handler; it does not keep a transport session
between calls.
The request's client capabilities must cover every input request. Ductor
supports elicitation/create, sampling/createMessage, and roots/list for
this flow. Missing capabilities return HTTP 400 with code -32021. Unsupported
input-request methods fail closed.
Tool catalog and governance
The catalog includes read, trigger, governance, and authoring tools. Ductor also projects connector actions and workflow triggers into the same catalog, so they can be called without a second execution path.
Descriptor-generated API reads
Ductor generates read and consent-gated mutation tools from the same canonical operation descriptors that produce the REST and OpenAPI surface. Bespoke tools also expose selected operations. Operations marked for internal use are excluded.
A generated read tool is eligible only when the operation is all of the following:
- available in the running API inventory;
- lifecycle
supportedand audiencetenant; - externally callable, not
internal_only; - covered by complete authentication and authorization metadata; and
- classified with the
readorpreviewauthorization action.
Tool names use api_ plus the normalized operation id—for example,
AIInferenceService_GetUsageStats becomes
api_aiinferenceservice_getusagestats. Input JSON Schema is derived from the
protobuf request: path fields become required, timestamps retain their format,
enums retain their closed values, and unknown fields are rejected.
For these descriptor-generated tools, tenant and environment identity are
never accepted from model arguments.
tenantId and environmentId fields are removed from the advertised schema and
injected from the authenticated MCP context. Execution forwards the caller's
credential to Ductor's fixed loopback REST listener, refuses redirects, applies
a bounded timeout, and caps response bodies at 10 MiB.
Projected mutations run only behind consent
Write, delete, cancel, execute, publish, promote, and other mutating
operations are projected too, as generated_write tools, but the built-in
exposure policy places them under confirmation_required (writes) or
approval_required (destructive). They are visible and callable, and a call
is authorized and receipted, but it does not execute until an operator
consents. Adding an OpenAPI mutation never makes it run unattended.
Bespoke clearing tools
The nine bespoke market tools use the same current resource authority as their canonical API counterparts. The server and operational backend check the authenticated principal and tenant on every direct or governed call, before application lookup or mutation; an old manifest or an exposure approval cannot restore a missing permission.
| Tools | Current resource permission |
|---|---|
routing.market.preview, routing.market.get, routing.market.replay | routing_market:read |
routing.market.open, routing.market.solicit, routing.market.close, routing.market.commit, routing.market.cancel | routing_market:write |
routing.market.submit_bid | routing_market_bid:write |
work_receipt.get, work_receipt.verify | work_receipt:read |
Each bespoke market tool requires environment_id inside arguments;
use the resolved canonical UUID and keep the governed invocation's environment
consistent. The application resolves and authorizes that environment, then
checks stored session scope before exposing or mutating it. Tenant identity
comes from authentication, and a bid's claimed principal can only echo the
authenticated caller. Worker ownership, eligibility, and disclosure remain
separate checks. Buyer market-write permission does not grant seller bidding;
operator and administrator bundles intentionally include both writes.
Generated tools instead inject environment from top-level invocation scope and
reject it inside arguments, as the bidder-observation example above shows.
Re-discover the exact tool after schema changes and preserve its lease,
version, and schema hash. The shared tenant-scoped published workflow can be
reused across environments; each execution retains its own authorized environment.
Receipt reads check the stored receipt's environment before returning canonical
bytes, so receipt-read permission alone does not authorize another environment.
Write tools and operator consent
A direct tools/call has nowhere to wait, so a consent-gated tool is refused
with the full reason, for example:
tool exposure denied "api_agentpresetservice_createagentpreset":
agent tool exposure: operator consent required: tool
"api_agentpresetservice_createagentpreset" is confirmation_required under
exposure policy builtin_safe_read v1 (write tool requires operator
confirmation); a direct call cannot wait for approval, so run it inside an
agent session that parks for approval, or have an operator add a rule
granting the tool mode "allow" through UpdateAgentToolExposurePolicy
tool exposure receipt: atr_… manifest: atm_…Two ways forward:
- Run inside an agent session. The runtime broker recognises the consent
decision, parks the turn on
requires_approval, and executes once an operator approves. This is how a governed agent performs writes. - Grant the tool. An operator creates a tenant exposure policy
(
POST /api/v2/agent-tool-exposure/policies, or theAgentToolExposureServiceRPCs) whose first rule selects the tool by name and sets modeallow. The nexttools/listmints a manifest under the new policy and the call executes directly. Every refusal and every post-consent execution leaves a tool exposure receipt, so the audit trail is complete either way.
Extend the built-in policy instead of replacing it
Set extends_builtin: true on the policy. Your rules match first; the
built-in rules (read → allow, destructive → approval_required,
write → confirmation_required) and the safe_summary redaction fill in
behind them, and your rules may reference safe_summary without
redeclaring it. Without the flag the highest-version active policy whose
selector matches governs alone, and the built-in builtin_safe_read policy
applies only when no tenant policy matches, so a replacing policy must carry
every rule and profile it needs or every read tool disappears.
{
"policy": {
"id": "passport-writes", "tenant_id": "…", "environment_id": "…",
"name": "Agents author presets and register themselves",
"version": 1, "status": "active", "default_mode": "hide",
"extends_builtin": true,
"rules": [{
"id": "passport-writes",
"selector": {"names": ["api_agentpresetservice_createagentpreset", "api_workerservice_registerworker"]},
"mode": "allow", "reason": "passport self-service",
"invocation_policy": {"execution_mode": "direct", "max_result_bytes": 65536},
"redaction_profile_id": "safe_summary"
}]
}
}Read tools (risk_labels: ["read"]) are allow under the built-in policy and
never need consent.
Projected api_* tools take and return one wire form: snake_case field names
(worker_id, display_label, preset_id), the same names the REST body uses
(definition_hash, created_at). A value copied from one response into the
next call needs no renaming. The pinned schema still rejects unknown
properties, so a client that sent protobuf JSON names (workerId) must re-read
the schema from tools/list; every lease already requires that.
When tool exposure is enabled, tools/list creates an explicit authorization
lease and manifest. The list response includes the identifiers and schema hashes
the client must echo on tools/call. Ductor checks the lease, manifest, tool
version, schema hash, tenant, and arguments again before execution.
This application-level lease is intentionally visible in the request. It is not an MCP session and does not require connection affinity.
Metering and errors
Every tools/call records a usage event under mcp.tool_calls with one of these
outcomes:
| Outcome | Meaning |
|---|---|
success | The tool completed normally. |
input_required | The tool returned a typed continuation request. |
application_error | The tool returned isError: true for the client to surface. |
error | Request or handler execution failed. |
denied | Tool exposure policy rejected the call before execution. |
Protocol failures use stable error codes:
| Failure | HTTP | JSON-RPC code |
|---|---|---|
| Header and body do not match | 400 | -32020 |
| Required client capability is missing | 400 | -32021 |
| Protocol version is unsupported | 400 | -32022 |
| Method is unknown | 404 | -32601 |
| Parameters or tool name are invalid | 400 or JSON-RPC response | -32602 |
Production checklist
- Keep both planes disabled until gateway authentication and authorization are configured.
- Roll out
/mcpfirst, then expose/mcp/adminonly through operator ingress. - Use a dedicated platform-admin machine identity for admin automation.
- Set explicit browser origins. Do not use
*in production. - Keep
expose_unwired_toolsoff. - Call both readiness tools and investigate unavailable families.
- In a sandbox tenant, test wrong and correct destructive confirmations.
- Create and revoke a short-lived key; confirm plaintext is delivered only to the intended secret store and never appears in audit output.
- Confirm clients send
2026-07-28metadata and all required routing headers. - Treat tool-lease fields and multi-round state as opaque values.
- Monitor
mcp.tool_callsoutcomes and JSON-RPC error codes during rollout. - Deploy clients and servers together. Ductor does not include an old-version compatibility path.
What an agent can—and cannot—administer
Together, the two planes let an agent bootstrap a tenant, govern membership and authority, issue and revoke scoped credentials, discover the tenant's allowed product tools, operate those tools, and disable access with auditable outcomes.
They do not expose the host that runs Ductor. Process and deployment control, static environment changes, migrations, raw SQL, backup and restore, raw secret reads, arbitrary HTTP/filesystem/shell execution, authorization-policy replacement, and retention-bypassing hard deletion remain in the deployment control plane or a documented break-glass procedure. This boundary keeps a compromised agent inside Ductor's product authorization model.
Related documentation
AI & Agents
Ductor's governed AI vertical — durable chat and workflow agents, provider-neutral inference, least-privilege tools, presets, and an inbound MCP server.
Agent Tool Security
The least-privilege exposure engine that decides which tools an agent session may see and call — per-session manifests, schema-hash binding, tenant policies, redaction, and decision receipts.