MCP Server¶
The orchestrator-core ships with an MCP (Model Context Protocol) server that exposes a curated subset of REST operations as tools for LLM agents. It is mounted on the FastAPI app at /mcp and shares the same authentication/authorization chain as the rest of the API.
The server is built on top of fastmcp and is auto-generated from the FastAPI routes — there is no separate tool registry to maintain. Tagging a route is the only thing needed to surface it.
Enabling¶
The MCP sub-app is opt-in:
-
Install the optional dependency:
pip install "orchestrator-core[mcp]" -
Set the environment variable:
MCP_ENABLED=True
When enabled, OrchestratorCore.__init__ mounts the sub-app at /mcp and composes its ASGI lifespan into the parent app’s lifespan so the stream-able HTTP session manager starts and stops with the orchestrator.
Transport: streamable HTTP (/mcp/). Configure your MCP client (e.g. Claude Desktop, an agent framework) to talk to that URL.
Usage¶
Once enabled, any MCP client can connect to https://<your-orchestrator>/mcp/ and call the generated tools.
Authentication uses the same bearer token as the REST API: the Authorization header on the inbound MCP request is forwarded into the in-process call so each route’s Depends(authorize) fires normally. See Auth(n|z).
The default toolset covers the typical agent flow:
| Tool | Purpose |
|---|---|
list_workflows |
Discover available workflows |
get_workflow_form |
Fetch a workflow’s input form (page by page) |
get_subscription_available_workflows |
List workflows available on a subscription |
create_workflow |
Start a new workflow process |
resume_workflow_process |
Resume a suspended process with form input |
abort_workflow_process |
Abort a running process (irreversible) |
get_process_status |
Inspect a running/suspended process |
list_recent_processes |
List recent processes (with typed filters) |
list_subscriptions |
List subscriptions, newest first |
get_subscription_details |
Get a flat header for a subscription |
list_products |
List products |
get_product |
Get one product definition by id |
get_product_block |
Get one product block definition by id |
get_resource_type |
Get one resource type definition by id |
get_workflow_by_id |
Get one workflow definition by id |
get_subscription_domain_model |
Get a subscription’s full product-block tree (large) |
get_process_status_counts |
Aggregate process/task counts grouped by status |
On top of these, AI / Hybrid Search contributes the search, aggregate, discover_filter_paths, get_valid_operators, resolve_entity and export_query tools.
Tool names map 1:1 to the route’s operation_id; tool descriptions come from the route’s docstring; parameter schemas come from the route’s Pydantic request model.
Workflow form pages for agents¶
get_workflow_form walks a workflow’s form page by page. Each result describes one page as schema: the page’s JSON Schema as one self-contained document, the same schema the browser renders but with every $ref replaced by its definition (a model that contains itself keeps its $ref and its entry under $defs). Read properties and required for what to submit. A property’s title and description are the field’s label and help text as the frontend shows them, from core’s form translations in the language asked (default en-GB); a field without one keeps the schema’s own title. A property with readOnly: true (a label, a divider, a summary table, a subscription shown for context) is shown to a person and takes no value: submit nothing for it. enum lists the values a property takes and, where a value has a label that differs from it, options maps value to label: submit the value. format: "accept" is a confirmation and takes "ACCEPTED". A nullable property is an anyOf with {"type": "null"}. The form’s widget hints (format, uniforms / extraProperties) are in the schema as the browser gets them; the products a productId field allows, for instance, are under uniforms.productIds.
status says what to do next: next (fill the page and call again with it appended to page_inputs), complete (call create_workflow with the same page_inputs) or rejected. A page that does not validate is reported as a tool error by default; with verdict: "result" it comes back as a result with status: "rejected", errors (loc, msg, type) and the page’s schema, so the caller can correct the values named and resubmit that page. create_workflow validates the pages again, so nothing starts on an ignored verdict.
The walk and the schema are pydantic-forms’: pydantic_forms.core.walk_form gives the next page, the result or the rejected page; pydantic_forms.core.page_schema(page, inline_refs=True) renders the page; pydantic_forms.core.form_errors turns a rejection into errors. Core keeps no copy of them.
Extending¶
To expose a new tool, add a FastAPI route tagged with AgentTag.EXPOSED. The route is then picked up automatically on the next app start — no MCP-specific glue code required.
from orchestrator.core.agent_tags import AgentTag
from fastapi import APIRouter
router = APIRouter()
@router.post(
"/cancel_process",
response_model=CancelProcessResponse,
tags=[AgentTag.EXPOSED],
operation_id="cancel_process",
)
def cancel_process_endpoint(params: CancelProcessRequest) -> CancelProcessResponse:
"""Cancel a running workflow process.
Use this when a process is stuck and must be aborted.
"""
...
Guidelines:
- Always set
operation_id— this becomes the MCP tool name. - Write a docstring — this becomes the tool description the LLM sees. Be explicit about preconditions and side effects.
- Use typed Pydantic models for inputs/outputs — the parameter schema is derived from them.
- Add
AgentTag.LARGEwhen the response may contain many records, so clients can warn the agent to narrow before calling. - Tag read-only
POST/PUTroutesAgentTag.READONLYand irreversible mutationsAgentTag.DESTRUCTIVE— these drive the tool’s annotations (see below). - Prefer placing LLM-specific routes in
orchestrator/core/api/api_v1/endpoints/mcp_tools.py. Use this when the existing REST shape is not LLM-friendly (e.g. flat positional filters, deprecated routes, oversized payloads). See the module docstring for the rationale of each existing curated tool.
Routes without AgentTag.EXPOSED are excluded from the MCP surface, regardless of whether they are public REST endpoints.
Tool annotations¶
Every generated tool carries MCP ToolAnnotations so clients can reason about safety (e.g. auto-approve reads). They are stamped by orchestrator.core.mcp.server._annotate (the fastmcp mcp_component_fn hook), derived from each route’s HTTP method plus its AgentTags:
readOnlyHint—GETroutes, or any route taggedAgentTag.READONLY.idempotentHint— read-only routes, orPUT/DELETE.destructiveHint—DELETEroutes, or any route taggedAgentTag.DESTRUCTIVE.openWorldHint— alwaysFalse(the orchestrator acts on its own database, not an open external world).title— a humanized form of theoperation_id(e.g.get_process_status→ “Get Process Status”).
The curated read tools in mcp_tools.py are POST routes, so they must be tagged AgentTag.READONLY to be marked safe; an irreversible action such as abort_workflow_process is tagged AgentTag.DESTRUCTIVE.
Implementation¶
The mount logic lives in orchestrator/core/mcp/server.py:
OrchestratorCore (FastAPI)
└── /mcp (Starlette sub-app, fastmcp streamable HTTP)
└── FastMCP.from_fastapi(app, route_maps=[EXPOSED → TOOL, * → EXCLUDE])
└── in-process httpx (ASGITransport) → FastAPI route
└── Depends(authorize)
Two things are worth noting:
- In-process invocation —
fastmcpcalls the FastAPI routes throughhttpxover anASGITransport. The request goes through the normal middleware + dependency chain, so authorization and exception handlers behave exactly as for an external REST call. - Authorization header forwarding —
fastmcp’s defaultget_http_headersexcludesauthorization.orchestrator.core.mcp.server._forward_auth_headeris registered as an httpx request hook to re-inject it soDepends(authorize)can validate the bearer token. See fastmcp#2817.