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

MCP servers (outbound)

This page covers an Agent connecting out to a third-party MCP (Model Context Protocol) server. An admin gives an agent a URL for a server you already have, AiFlow syncs it, and every tool that server exposes becomes available to the agent.

This is the opposite direction from AiFlow's own MCP server

MCP server documents the other direction: an external MCP client (Claude Desktop, another agent framework) calling into AiFlow, using AiFlow's own three tools (place_outbound_call, search_knowledge_base, fetch_call_transcript). This page is about an AiFlow Agent acting as the MCP client, reaching out to a server someone else runs. The two are unrelated to each other: connecting an agent to an external server here has no effect on what AiFlow exposes at /api/v1/mcp/, and vice versa.

Admin role required

GET /api/v1/agents/{agent_id}/mcp-servers and GET /api/v1/mcp-servers/catalog need any logged-in admin (Owner, Admin, Editor, or Viewer). POST, PATCH, DELETE, and the sync action all need Owner or Admin.

Transport: Streamable HTTP only

AiFlow speaks only the Streamable HTTP transport; there is no stdio or local-process server support. A stdio server runs as a child process on whatever machine starts it, and supporting that here would mean AiFlow executing arbitrary code on your behalf, which is explicitly out of scope. If you're building your own server to connect an agent to, rather than pointing at an existing public one, see the official MCP documentation for the protocol spec, specifically its Streamable HTTP transport.

Field reference

Field Type Required on create Default Description
name string Yes None 1 to 120 characters. A label for this connection, shown in the admin dashboard.
url string Yes None 1 to 500 characters. The remote MCP server's Streamable HTTP endpoint.
enabled boolean No true Whether the agent can use this connection's tools right now.
auth_type string No "static_header" "static_header" or "oauth". See OAuth below for the second mode.
disabled_tools array of strings or null No null Names of this connection's own discovered_tools to exclude from the agent's session, for a server that exposes more tools than are actually wanted. On PATCH, omitting this field leaves it unchanged; sending [] re-enables everything. A newly discovered tool after a re-sync is enabled by default, not hidden.
auth_header_value string or null No null Write-only, only meaningful when auth_type is "static_header". If set, sent as the value of an Authorization header on every call to the remote server. Encrypted at rest; never returned by any read endpoint, only has_auth_header (a boolean) tells you whether one is set. On PATCH, omitting this field (or sending null) leaves the stored value unchanged; sending an empty string "" clears it.

Response shape

GET/POST/PATCH on a connection all return this shape:

{
  "id": 1,
  "agent_id": 1,
  "name": "DeepWiki",
  "url": "https://mcp.deepwiki.com/mcp",
  "enabled": true,
  "auth_type": "static_header",
  "has_auth_header": false,
  "oauth_connected": false,
  "oauth_last_error": null,
  "last_synced_at": "2026-01-15T10:31:00Z",
  "last_sync_error": null,
  "discovered_tools": [
    {
      "name": "ask_question",
      "description": "Ask a question about a public GitHub repository."
    }
  ],
  "disabled_tools": []
}

discovered_tools is null until the connection has been synced at least once (see below). last_sync_error holds the most recent sync failure's message, or null if the last sync succeeded or none has run yet. disabled_tools is null until an admin has excluded at least one discovered tool by name via PATCH (see below); in the admin dashboard, each discovered-tool chip on a synced connection toggles this directly. oauth_connected and oauth_last_error are only meaningful when auth_type is "oauth", see below.

Create a connection

Capped by your licence

This deployment's licence sets an mcp_connections limit. Creating one more past that cap returns 402 rather than succeeding; see Licence limits.

POST /api/v1/agents/{agent_id}/mcp-servers
curl -X POST https://api.your-domain.com/api/v1/agents/1/mcp-servers \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"name": "DeepWiki", "url": "https://mcp.deepwiki.com/mcp"}'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/agents/1/mcp-servers",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"name": "DeepWiki", "url": "https://mcp.deepwiki.com/mcp"},
)
response.raise_for_status()
connection = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/mcp-servers",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({
      name: "DeepWiki",
      url: "https://mcp.deepwiki.com/mcp",
    }),
  },
);
const connection = await response.json();

Returns 201 Created with the shape shown above. discovered_tools starts null: no tools are actually available to the agent yet, since a sync is a separate, explicit step (next).

For a server that requires authentication, add auth_header_value:

{
  "name": "Orders API",
  "url": "https://mcp.orders.example.com/mcp",
  "auth_header_value": "Bearer sk_live_xxx"
}

Sync a connection

POST /api/v1/agents/{agent_id}/mcp-servers/{connection_id}/sync

Calls tools/list on the remote server and caches the result. AiFlow doesn't hold a long-lived connection to the remote server open between sessions: every session an agent has re-reads whatever was cached at the last sync. It doesn't sync automatically or on a schedule, so re-run this whenever the remote server's own tool set changes.

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

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

A successful sync returns 200 OK with the connection's discovered tool count:

{ "synced": true, "tool_count": 3, "error": null }

A sync that fails to reach or read from the remote server still returns 200 OK (not a 4xx or 5xx), with synced: false and a message in error. The connection's own enabled state and previously cached discovered_tools are left untouched:

{
  "synced": false,
  "tool_count": 0,
  "error": "Connection timed out after 10.0s"
}
Field Type Description
synced boolean Whether this sync attempt succeeded.
tool_count integer Number of tools discovered on a successful sync; 0 on failure.
error string or null The failure message, or null on success.

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

The alternative to auth_header_value: instead of an admin pasting in a static token, an agent 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. No provider-specific AiFlow code is written for this the way the Salesforce and HubSpot connectors in CRM connectors need, since dynamic client registration means AiFlow never needs a provider's own client ID or secret configured up front: it registers itself with whichever server it's pointed at, the first time an admin connects.

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/agents/{agent_id}/mcp-servers
{"name": "Team Docs", "url": "https://mcp.example.com/mcp", "auth_type": "oauth"}
GET /api/v1/agents/{agent_id}/mcp-servers/{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-servers reflects the connection's new oauth_connected (true or false) and oauth_last_error (set on failure). Access tokens refresh automatically, ahead of expiry, on the next call that needs one. A refresh failure, such as a revoked grant, is recorded in oauth_last_error and surfaced the same way a sync failure is; reconnecting (re-running the same authorize-url flow) is the recovery path.

A 422 from the authorize-url endpoint means the target server doesn't support the OAuth discovery this depends on: no .well-known/oauth-authorization-server metadata reachable, or no registration_endpoint in it. That server needs a pre-registered, provider-specific client instead, which is exactly what auth_header_value (a manually obtained static token) or a future first-class connector would cover, not this generic path.

The catalog

GET /api/v1/mcp-servers/catalog

A curated list of public MCP servers for popular corporate tools (Notion, Linear, Stripe, Cloudflare, and more), each verified end to end: actually running discovery, and for an oauth entry dynamic client registration (DCR), against the real server, not just read off a vendor's documentation page. It's meant as a one-click starting point in the admin dashboard's connection form, browsable as a searchable gallery grouped by category, not an exhaustive directory of every public MCP server available. The gallery also ends in a "Don't see what you're looking for?" callout linking out to MeridFlow's own contact page, for a server not yet in the catalog.

Every entry defaults to oauth except DeepWiki, which needs no auth at all. Some hosted servers cannot complete dynamic registration at all. They may publish no registration_endpoint, enforce a closed allowlist of pre-approved clients whatever the request says, or accept only a loopback redirect URI.

None of those appear in the catalog as oauth. If a real request confirms the server takes a plain bearer token, it is listed as static_header instead, which is how GitHub, PagerDuty, Perplexity, and Tavily are listed today. Otherwise it is left out, rather than shipped as a button that can never connect.

A closed allowlist cannot be worked around generically. Those vendors support OAuth only for clients they have reviewed and approved in advance, the status Claude and Cursor hold with them, never for a caller arriving cold. See Orchestrators: OAuth.

Every entry is also a real, single, vendor-hosted URL that works the same for every admin. A server that only exists per-tenant, needing a placeholder segment in its URL edited before it works, isn't included either.

Field Type Notes
name string A human-readable display name.
url string The server's Streamable HTTP endpoint, prefilled into url on Create a connection if chosen.
description string A short summary of what the server exposes.
auth_type string enum static_header or oauth, prefilled into auth_type so the connection form starts in the right mode for that server.
logo string A lookup key into the admin UI's own icon set, not binary image data or an external URL.
category string Groups entries in the admin UI's catalog gallery (Development, Project management, Communication, Sales & payments, Operations, Web search & research, Knowledge).
curl https://api.your-domain.com/api/v1/mcp-servers/catalog \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/mcp-servers/catalog",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
catalog = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/mcp-servers/catalog",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const catalog = await response.json();
[
  {
    "name": "DeepWiki",
    "url": "https://mcp.deepwiki.com/mcp",
    "description": "Ask questions about any public GitHub repository's structure and docs.",
    "auth_type": "static_header",
    "logo": "deepwiki",
    "category": "Knowledge"
  },
  {
    "name": "GitHub",
    "url": "https://api.githubcopilot.com/mcp/",
    "description": "Repositories, issues, pull requests, and code search. Doesn't support dynamic client registration; paste a personal access token as 'Bearer <token>' instead of connecting via OAuth.",
    "auth_type": "static_header",
    "logo": "github",
    "category": "Development"
  },
  {
    "name": "Notion",
    "url": "https://mcp.notion.com/mcp",
    "description": "Pages, databases, comments, and workspace search.",
    "auth_type": "oauth",
    "logo": "notion",
    "category": "Project management"
  }
]

Each entry is just name, url, and description: a starting point you still need to create a real connection from via POST above. This endpoint itself creates nothing.

List an agent's connections

GET /api/v1/agents/{agent_id}/mcp-servers
curl https://api.your-domain.com/api/v1/agents/1/mcp-servers \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

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

Update a connection

PATCH /api/v1/agents/{agent_id}/mcp-servers/{connection_id}

name, url, enabled, disabled_tools, and auth_header_value are all optional. Send only what you're changing, and remember the empty-string-clears-the-credential behavior on auth_header_value described in the field reference above.

curl -X PATCH https://api.your-domain.com/api/v1/agents/1/mcp-servers/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/agents/1/mcp-servers/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/agents/1/mcp-servers/1",
  {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${adminToken}`,
    },
    body: JSON.stringify({ enabled: false }),
  },
);
const connection = await response.json();

To exclude specific tools from a server exposing more than an agent needs, send disabled_tools with the discovered tool names to hide:

curl -X PATCH https://api.your-domain.com/api/v1/agents/1/mcp-servers/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"disabled_tools": ["delete_channel", "post_message"]}'

Delete a connection

DELETE /api/v1/agents/{agent_id}/mcp-servers/{connection_id}
curl -X DELETE https://api.your-domain.com/api/v1/agents/1/mcp-servers/1 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

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

Returns 204 No Content. This is a hard delete with no soft-disable path. Use PATCH {"enabled": false} instead if you want to temporarily stop the agent from using it without losing the connection's configuration.

Next

  • MCP server: the inbound direction, AiFlow acting as an MCP server for an external client.
  • Tools: the built-in catalog every agent starts with.
  • Custom tools: the other way to extend an agent without an existing MCP server to point at.