Skip to content
MeridFlow AiFlow v8.x • self-hosted

Orchestrator MCP servers

An Orchestrator can connect to external MCP (Model Context Protocol) servers the same way a native Agent can, but through a completely separate set of connections: connecting an MCP server to Orchestrator 1 has no effect on what a native Agent, or any other Orchestrator, can reach, and vice versa. This page covers two distinct resources that share the same shape:

  1. Per-Orchestrator MCP connections: reachable only by the one Orchestrator they're attached to.
  2. The shared MCP connection pool: reachable automatically by every Orchestrator in the deployment, with no per-Orchestrator attach step.

Both are covered in full below. Use the per-Orchestrator resource if you only need one Orchestrator talking to one MCP server. Use the shared pool instead if you have an MCP server, say, an internal orders API, that every Orchestrator you build should be able to reach, rather than wiring up the same connection on each one individually.

The admin dashboard's + Add MCP server button on either surface opens the same browsable, searchable catalog gallery a native Agent's MCP servers tab uses, see MCP servers: The catalog; picking an entry prefills the connection form, including the right auth_type for that server.

Gated behind a deployment setting, admin session token required

See the two notes at the top of Orchestrators: every endpoint here only exists when your deployment has Orchestrators turned on, and every endpoint here authenticates with an admin session token.

Nothing here is cached

This differs from a native Agent's MCP connections. A native Agent's MCP connection stores a synced list of the tools it discovered; an Orchestrator's doesn't. The Test action below (POST .../test) calls tools/list on the remote server live, every time you call it, and returns whatever comes back transiently. Nothing about the result is written to the connection row. An Orchestrator's actual tool discovery, when it runs a real delegated task, works the same way: live, on every call, never from a stored cache.

The connection object

Both the per-Orchestrator and shared resources use this same shape:

Field Type Notes
id integer Response only.
orchestrator_id integer or null Response only. The owning Orchestrator's id for a per-Orchestrator connection, or null for a shared connection. This is the only field that distinguishes the two; see Per-Orchestrator vs. shared below.
name string Required on create, 1 to 120 characters. A label to recognize this connection by later.
url string Required on create, 1 to 500 characters. The MCP server's Streamable HTTP endpoint.
enabled boolean Defaults to true on create.
auth_type string enum static_header (default) or oauth. See OAuth below.
auth_header_value string or null Write-only. Accepted on create and update, never returned in any response. Encrypted at rest. Ignored when auth_type is oauth. Same three-state PATCH semantics as an Orchestrator's remote_auth_header_value, see Remote auth headers are write-only: omit to leave unchanged, send "" to clear, send a non-empty string to replace.
has_auth_header boolean Response only. true if an auth_header_value is currently stored, false otherwise.
oauth_connected boolean Response only. true once an OAuth connection has completed a successful token exchange.
oauth_last_error string or null Response only. Set when the most recent OAuth authorize or refresh attempt failed; null otherwise.

Per-Orchestrator vs. shared

Both resources are stored the same way and return the same object shape; the only structural difference is whether orchestrator_id is set. A per-Orchestrator connection is reachable only through paths that include {orchestrator_id}. A shared connection is created and managed through paths with no {orchestrator_id} segment at all, and is available to every Orchestrator in the deployment automatically, with no separate "attach this connection to Orchestrator N" step anywhere in this API.

Per-Orchestrator MCP connections

Base path: /api/v1/orchestrators/{orchestrator_id}/mcp-connections

List

GET /api/v1/orchestrators/{orchestrator_id}/mcp-connections

Requires: Viewer and above.

curl https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
connections = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const connections = await response.json();
[
  {
    "id": 1,
    "orchestrator_id": 1,
    "name": "Orders API",
    "url": "https://mcp.orders.example.com/mcp",
    "enabled": true,
    "auth_type": "static_header",
    "has_auth_header": true,
    "oauth_connected": false,
    "oauth_last_error": null
  }
]

A nonexistent orchestrator_id gets a 404.

Create

POST /api/v1/orchestrators/{orchestrator_id}/mcp-connections

Requires: Editor and above.

Field Type Required Default
name string Yes none
url string Yes none
enabled boolean No true
auth_type string enum No "static_header"
auth_header_value string or null No null
curl -X POST https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "name": "Orders API",
        "url": "https://mcp.orders.example.com/mcp",
        "auth_header_value": "Bearer sk_live_..."
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "name": "Orders API",
        "url": "https://mcp.orders.example.com/mcp",
        "auth_header_value": "Bearer sk_live_...",
    },
)
response.raise_for_status()
connection = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({
      name: "Orders API",
      url: "https://mcp.orders.example.com/mcp",
      auth_header_value: "Bearer sk_live_...",
    }),
  },
);
const connection = await response.json();

Returns 201 Created with the full connection object. auth_header_value is never echoed back; check has_auth_header to confirm it was stored. A nonexistent orchestrator_id gets a 404.

Update

PATCH /api/v1/orchestrators/{orchestrator_id}/mcp-connections/{connection_id}

Requires: Editor and above.

Every field is optional; only the fields present in the request body are changed.

Field Type
name string
url string
enabled boolean
auth_header_value string or null
curl -X PATCH https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"enabled": false}'
import httpx

response = httpx.patch(
    "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"enabled": False},
)
response.raise_for_status()
connection = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1",
  {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({ enabled: false }),
  },
);
const connection = await response.json();

Returns 200 OK with the full, updated connection object. A nonexistent connection_id (or one belonging to a different Orchestrator) gets a 404.

Delete

DELETE /api/v1/orchestrators/{orchestrator_id}/mcp-connections/{connection_id}

Requires: Editor and above.

curl -X DELETE https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.delete(
    "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1",
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${adminToken}` },
  },
);

Returns 204 No Content. A nonexistent connection_id gets a 404.

Test

POST /api/v1/orchestrators/{orchestrator_id}/mcp-connections/{connection_id}/test

Requires: Editor and above.

Connects to the MCP server right now, calls tools/list on it, and returns what came back. Nothing about the result is stored, as covered in the note at the top of this page; call it again and it goes back to the remote server again.

Field Type Notes
reachable boolean Whether the connection attempt succeeded.
tool_count integer Defaults to 0. The number of tools the server reported.
tools array of objects Defaults to an empty array. Each object has name (string), description (string), and input_schema (object, the tool's JSON-Schema input definition).
error string or null Defaults to null. Set to the underlying error's message when reachable is false.

This endpoint itself always returns 200 OK, even when the remote server couldn't be reached; check reachable, not the HTTP status, to know whether the test succeeded.

curl -X POST https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1/test \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1/test",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
result = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrators/1/mcp-connections/1/test",
  {
    method: "POST",
    headers: { Authorization: `Bearer ${adminToken}` },
  },
);
const result = await response.json();

A reachable server:

{
  "reachable": true,
  "tool_count": 2,
  "tools": [
    {
      "name": "lookup_order",
      "description": "Looks up an order by its order number.",
      "input_schema": {
        "type": "object",
        "properties": { "order_number": { "type": "string" } },
        "required": ["order_number"]
      }
    },
    {
      "name": "list_recent_orders",
      "description": "Lists orders placed in the last N days.",
      "input_schema": {
        "type": "object",
        "properties": { "days": { "type": "integer" } }
      }
    }
  ],
  "error": null
}

An unreachable server:

{
  "reachable": false,
  "tool_count": 0,
  "tools": [],
  "error": "Connection refused"
}

A nonexistent orchestrator_id or connection_id gets a 404.

OAuth (any spec-compliant server, no AiFlow code)

The alternative to auth_header_value: instead of an admin pasting in a static token, an Orchestrator can connect to any remote MCP server that implements OAuth 2.1 with dynamic client registration (RFC 7591), by having the admin click Connect and complete that server's own consent screen in a browser. The same generic, no-provider-specific-code mechanism a native Agent's MCP connections use, see MCP servers: OAuth for the full explanation.

Create the connection with "auth_type": "oauth" (no auth_header_value needed), then fetch the authorize URL and open it in a browser:

POST /api/v1/orchestrators/{orchestrator_id}/mcp-connections
{"name": "Team Docs", "url": "https://mcp.example.com/mcp", "auth_type": "oauth"}
GET /api/v1/orchestrators/{orchestrator_id}/mcp-connections/{connection_id}/oauth/authorize-url
{
  "authorize_url": "https://auth.example.com/authorize?client_id=...&code_challenge=..."
}

The server redirects back to AiFlow's own callback once the admin approves. A small static page confirms success or reports what went wrong, then GET .../mcp-connections reflects the connection's new oauth_connected (true or false) and oauth_last_error (set on failure). Access tokens refresh automatically, ahead of expiry, the next time the Orchestrator's agent tree is built and actually calls the server. A 422 from the authorize-url endpoint means the target server doesn't support OAuth discovery, the same failure mode and recovery path MCP servers: OAuth covers.

The shared MCP connection pool

Base path: /api/v1/orchestrator-mcp-connections, with no {orchestrator_id} segment anywhere. Every endpoint here mirrors its per-Orchestrator counterpart above exactly: same fields, same request and response shapes, same role requirements. The only differences are the base path, and that every connection created here always has orchestrator_id: null in its response and is immediately usable by every Orchestrator in the deployment.

Create

POST /api/v1/orchestrator-mcp-connections

Requires: Editor and above.

curl -X POST https://api.your-domain.com/api/v1/orchestrator-mcp-connections \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "name": "Company Directory",
        "url": "https://mcp.directory.example.com/mcp",
        "auth_header_value": "Bearer sk_live_..."
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/orchestrator-mcp-connections",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "name": "Company Directory",
        "url": "https://mcp.directory.example.com/mcp",
        "auth_header_value": "Bearer sk_live_...",
    },
)
response.raise_for_status()
connection = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/orchestrator-mcp-connections",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({
      name: "Company Directory",
      url: "https://mcp.directory.example.com/mcp",
      auth_header_value: "Bearer sk_live_...",
    }),
  },
);
const connection = await response.json();
{
  "id": 4,
  "orchestrator_id": null,
  "name": "Company Directory",
  "url": "https://mcp.directory.example.com/mcp",
  "enabled": true,
  "auth_type": "static_header",
  "has_auth_header": true,
  "oauth_connected": false,
  "oauth_last_error": null
}

orchestrator_id is always null in the response here; there's no way to set it to anything else through this base path. Since there's no id to submit on create, there's no parent-existence check to fail here, unlike the per-Orchestrator variant above.

List, update, delete, test, and OAuth

These behave identically to their per-Orchestrator counterparts above, just without the {orchestrator_id} path segment; every connection returned has orchestrator_id: null.

Action Method & path Requires
List GET /api/v1/orchestrator-mcp-connections Viewer and above
Update PATCH /api/v1/orchestrator-mcp-connections/{connection_id} Editor and above
Delete DELETE /api/v1/orchestrator-mcp-connections/{connection_id} Editor and above
Test POST /api/v1/orchestrator-mcp-connections/{connection_id}/test Editor and above
OAuth authorize URL GET /api/v1/orchestrator-mcp-connections/{connection_id}/oauth/authorize-url Editor and above

For example, testing a shared connection:

curl -X POST https://api.your-domain.com/api/v1/orchestrator-mcp-connections/4/test \
  -H "Authorization: Bearer $ADMIN_TOKEN"

returns the exact same {"reachable": ..., "tool_count": ..., "tools": [...], "error": ...} shape documented in Test above. A connection_id that belongs to a per-Orchestrator connection, that is, one whose orchestrator_id isn't null, isn't reachable through this base path and gets a 404 here. The reverse is also true: a shared connection's id isn't reachable through /api/v1/orchestrators/{orchestrator_id}/mcp-connections/{connection_id} either. The two id spaces overlap numerically, since they're the same database table, but each base path only ever resolves rows matching its own orchestrator_id IS NULL or orchestrator_id = {orchestrator_id} condition.