Skip to main content

MCP integration

Model Context Protocol (MCP) integration connects the gateway to external tool servers. When mcp_servers is configured, the gateway advertises those servers' tools to the model and runs the full agentic loop itself โ€” calling tools over MCP, feeding results back to the model, and repeating until a final answer comes back. Clients get a normal chat completion response; the tool round-trips are invisible.

Two transports are supported: Streamable HTTP (url) for a running MCP endpoint, and stdio (command + args) for a subprocess the gateway launches and owns for its own lifetime โ€” any npx or uvx MCP server works without standing up an HTTP endpoint for it. Each mcp_servers entry sets exactly one of url or command; setting both, or neither, is a config error naming the server.

How it worksโ€‹

Server initialization (the initialize + tools/list handshake) happens once in a background goroutine when the gateway starts, not per request โ€” the gateway is ready to serve immediately, and MCP tool injection begins once that background init completes. Call gateway.MCPInitDone() to get a channel that closes when initialization finishes.

Tools are injected only when the caller sends none

MCP tools are added to a chat completion only if the request's own tools array is empty. If a client sends its own tools, the gateway leaves the request untouched โ€” MCP does not participate, and standard client-side function calling works exactly as it would with MCP disabled. This avoids a turn where the model mixes one MCP-owned call with one caller-owned call that neither side can resolve.

Configurationโ€‹

Add mcp_servers to your config.yaml. A server entry uses either url (HTTP) or command (stdio):

mcp_servers:
# stdio transport โ€” launched as a subprocess at gateway startup
- name: filesystem
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
env: # the subprocess inherits NO gateway environment
SOME_TOKEN: "${SOME_TOKEN}"
timeout_seconds: 30 # per tool call; default 30
required: false # default false; true gates /readyz on this server

# Streamable HTTP transport โ€” the gateway connects to a running endpoint
- name: database
url: "https://mcp-db.internal/mcp"
headers:
Authorization: "Bearer ${MCP_DB_TOKEN}"
allowed_tools:
- query_readonly
- list_tables
timeout_seconds: 15
max_call_depth: 5

Configuration fieldsโ€‹

FieldRequiredDefaultDescription
nameYesโ€”Unique identifier for this server. Used in logs, metrics labels, and the /readyz body
urlExactly one of url | commandโ€”Streamable HTTP endpoint. Selects the HTTP transport
commandExactly one of url | commandโ€”Executable to launch as an MCP stdio server. Selects the stdio transport; the subprocess starts at gateway init and lives for the gateway's lifetime
argsNo[]Command-line arguments passed to command (stdio only)
envNo{}Environment injected into the stdio subprocess (stdio only) โ€” see Subprocess environment isolation
headersNo{}HTTP headers sent on every MCP request (HTTP only). Supports ${VAR} interpolation
allowed_toolsNoall toolsIf set, only these tool names from this server are discovered and exposed to the model
timeout_secondsNo30Per-tool-call timeout for this server, both transports
max_call_depthNo5Bound on the agentic loop's turn depth. The minimum positive value across all registered servers is used
requiredNofalseMakes this server's readiness a condition of /readyz โ€” see Readiness and required servers

Subprocess environment isolationโ€‹

A stdio MCP subprocess does not inherit the gateway's environment. It receives a minimal base โ€” PATH, HOME, LANG, TMPDIR when set โ€” plus exactly the keys listed in that server's env, which override the base. Gateway credentials such as OPENAI_API_KEY or MASTER_KEY never reach a subprocess implicitly.

This is isolation from implicit inheritance, not a prohibition: a value placed in env deliberately โ€” including a credential, including one drawn from the gateway's own environment via ${VAR} โ€” is passed through as configured. Anything a server needs beyond the base four variables (HTTPS_PROXY, NODE_PATH, SSL_CERT_FILE, and on Windows SYSTEMROOT/APPDATA) must be listed explicitly.

${VAR} references in both headers and env are resolved when the MCP client is constructed, not when the config is loaded โ€” so the config itself never stores the materialized secret, and it never appears in the config-history store or GET /admin/config. Only the braced form is a reference; a bare $ is literal data, and an undefined variable is an error.

mcp_servers:
- name: filesystem
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
env:
SOME_TOKEN: "${SOME_TOKEN}" # resolved at client construction; ${โ€ฆ} only

The subprocess's stderr is drained continuously and logged at debug level (the MCP spec treats stderr output as diagnostic, not an error) โ€” this also prevents a full OS pipe buffer from blocking the child mid-write and stalling JSON-RPC.

Tool access controlโ€‹

Use allowed_tools to restrict which tools from an MCP server are exposed to the model. Filtered tools are never discovered or advertised:

mcp_servers:
- name: database
url: "https://mcp-db.internal/mcp"
allowed_tools:
- query_readonly
- list_tables
# write/delete tools from this server are NOT injected

Readiness and required serversโ€‹

mcp_servers[].required (default false) makes one server's availability a condition of instance readiness. Every configured server's state is reported in the GET /readyz body regardless of required, so MCP health can be observed without gating on it:

requiredServer unready/readyz
absent / falsereported in the body200 ready
truereported in the body503, reason required mcp server unavailable

A server is unready when it never completed the initialize handshake โ€” including one whose transport could not even be built (an unresolvable ${VAR} in headers or env). Death after a successful handshake is detected for stdio servers only: a crashed subprocess is noticed (its stderr pipe closing, confirmed with an MCP ping before anything is withdrawn) and its tools are withdrawn from the model. An HTTP server that becomes unreachable after a successful handshake is not currently detected โ€” it keeps reporting ready with its tools advertised, and calls to it fail per request. Don't rely on required: true to pull an instance out of rotation when an HTTP MCP server goes down.

The failure reason is deliberately omitted from the unauthenticated /readyz body, since it can quote a server URL, an authorization header, or a subprocess command line. It's logged server-side and served on the bearer-authenticated GET /admin/health instead (read_only or admin scope).

Set required: true only for a server the deployment genuinely cannot serve without โ€” a required server that's down stops all traffic through the instance, including requests that use no tools at all.

Agentic loop mechanicsโ€‹

Once MCP is active for a request, the executor loops until the model stops requesting tool calls or max_call_depth is reached:

  • Ownership. A tool call is only executed if the gateway's registry owns that tool name. A choice mixing an MCP-owned call with a caller-owned call is never half-executed โ€” it's handed back to the client whole, since the provider would reject an unmatched tool_call_id.
  • Depth limit. At the depth limit, pending MCP-owned tool calls are dropped and the response finishes with finish_reason: "length" rather than asking the client to satisfy tools it never declared.
  • Guardrails run on every turn. Every turn of the loop passes through the same before_request plugins (guardrails, rate limiting, budget) that gate the initial provider call โ€” not just the first turn. Tool results are content the caller never wrote and the operator has the least reason to trust, so a guardrail can reject a request mid-loop, and a budget check can stop an overspending loop partway through rather than after it completes.
  • Failures reach the model as generic messages (timed out, server unavailable, the tool call failed) โ€” the real error, which can quote URLs or command lines, is logged server-side only.
Security note

Allowing the model to execute tool calls introduces risk, and tool results are the least-trusted content in the loop โ€” an external MCP server, not the caller, produced them. Guardrails now run every turn for this reason. Additionally:

  • Use allowed_tools to expose only the tools the model needs
  • Prefer read-only tools where possible
  • Set max_call_depth conservatively (3โ€“5 is usually sufficient)
  • Validate and sanitise all data before it reaches write-capable tools

Observabilityโ€‹

MCP exposes dedicated Prometheus series on /metrics:

MetricLabelsDescription
gateway_mcp_server_upserver_name1 when a server completed its handshake and its transport is alive, 0 otherwise
gateway_mcp_server_init_failures_totalserver_nameIncremented each time a server fails to initialize
ferrogw_mcp_tool_calls_totalserver_name, tool_name, statusTotal MCP tool calls (status is ok or error)
ferrogw_mcp_tool_call_duration_secondsserver_name, tool_nameLatency histogram for individual tool calls
ferrogw_mcp_unknown_tool_calls_totaltool_nameTool calls the model requested for a name no registered server advertises

Example: local filesystem tools via stdioโ€‹

Point the gateway at a filesystem MCP server with no separate process to manage โ€” the gateway launches it:

mcp_servers:
- name: filesystem
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"]
allowed_tools: [read_file, list_directory, search_files]
max_call_depth: 4

Then ask the model a question that requires reading files. Send no tools array so MCP participates:

response = client.chat.completions.create(
model="claude-sonnet-4-5",
messages=[{
"role": "user",
"content": "What tests are failing in the src/gateway_test.go file?"
}],
)
# The gateway reads the file via MCP, sends content to the model, returns the answer
print(response.choices[0].message.content)

Example: remote HTTP server with authโ€‹

mcp_servers:
- name: secure-tools
url: "https://tools.internal/mcp"
headers:
Authorization: "Bearer ${MCP_TOOLS_TOKEN}"
X-Tenant-ID: "acme-corp"

Streaming requestsโ€‹

Clients may send stream: true when MCP is active. The gateway diverts streaming requests through the full (non-streaming) agentic loop internally โ€” every tool call is resolved inside the gateway โ€” then delivers the final answer as a single SSE chunk once the loop completes. Clients receive correct SSE output without handling intermediate tool-call messages, but the response is not token-by-token for MCP-driven turns:

response = client.chat.completions.create(
model="claude-sonnet-4-5",
stream=True,
messages=[{"role": "user", "content": "List the failing tests in gateway_test.go"}],
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")