AI & Agents

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 planeDefault routeIdentity boundaryPurpose
Consumer/mcpAuthenticated, active tenantOperate the products and workflows that tenant policy exposes.
Admin/mcp/adminAuthenticated platform-adminProvision 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.

ductor.yaml
mcp:
  enabled: true
  path: /mcp
  admin_enabled: true
  admin_path: /mcp/admin
  expose_unwired_tools: false

cors:
  allowed_origins: "https://console.example.com"
  • mcp.enabled mounts the endpoint when set to true.
  • mcp.path sets the mount path. The default is /mcp.
  • mcp.admin_enabled mounts the platform-admin endpoint when set to true.
  • mcp.admin_path sets its distinct mount path. The default is /mcp/admin.
  • mcp.expose_unwired_tools controls whether discovery includes tools without a live backend. Keep it false in production.
  • cors.allowed_origins controls 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:

DomainRead operationsMutations
Readinessadmin.readiness—
Tenantsadmin.tenant.list, admin.tenant.getadmin.tenant.create, admin.tenant.update, admin.tenant.delete
Membersadmin.member.list, get, roles, scopesassign_role, invite, revoke_invite, disable, reenable, remove
API keysadmin.api_key.listadmin.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.

TargetRequired 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:

  1. The first call validates the tenant, name, roles, scopes, environment restrictions, and optional expiry.
  2. Ductor returns input_required, an elicitation/create request, and opaque state bound to those arguments.
  3. The client collects typed approval and retries the same call with unchanged arguments, matching requestState, and inputResponses.
  4. 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:

server/discover version, capabilities, cache hints tools/list authorized tool catalog tools/call complete or input_required MCP client Ductor MCP server
MethodPurposeIdentity context
server/discoverReturns supported versions, capabilities, instructions, identity, and cache hints.Authenticated tenant or platform admin
tools/listReturns the deterministic catalog visible to this caller.Authenticated tenant or platform admin
tools/callRe-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, either complete or input_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:

Task-scoped tools/list params
{
  "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:

Bidder-observation tools/list params
{
  "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:

Bidder-observation tools/call params
{
  "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 supported and audience tenant;
  • externally callable, not internal_only;
  • covered by complete authentication and authorization metadata; and
  • classified with the read or preview authorization 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.

ToolsCurrent resource permission
routing.market.preview, routing.market.get, routing.market.replayrouting_market:read
routing.market.open, routing.market.solicit, routing.market.close, routing.market.commit, routing.market.cancelrouting_market:write
routing.market.submit_bidrouting_market_bid:write
work_receipt.get, work_receipt.verifywork_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.

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 the AgentToolExposureService RPCs) whose first rule selects the tool by name and sets mode allow. The next tools/list mints 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:

OutcomeMeaning
successThe tool completed normally.
input_requiredThe tool returned a typed continuation request.
application_errorThe tool returned isError: true for the client to surface.
errorRequest or handler execution failed.
deniedTool exposure policy rejected the call before execution.

Protocol failures use stable error codes:

FailureHTTPJSON-RPC code
Header and body do not match400-32020
Required client capability is missing400-32021
Protocol version is unsupported400-32022
Method is unknown404-32601
Parameters or tool name are invalid400 or JSON-RPC response-32602

Production checklist

  • Keep both planes disabled until gateway authentication and authorization are configured.
  • Roll out /mcp first, then expose /mcp/admin only 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_tools off.
  • 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-28 metadata and all required routing headers.
  • Treat tool-lease fields and multi-round state as opaque values.
  • Monitor mcp.tool_calls outcomes 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.