Gateway-Run MCP Loop vs. Passthrough: Why Agent Tool Calling Belongs at the AI Gateway
TL;DR: There are three ways to connect Model Context Protocol (MCP) tool servers to an LLM: inside your client code, via an Anthropic-only passthrough, or inside the AI gateway. Running the MCP loop at the gateway converts MCP tools into standard function declarations before dispatch, executes tool calls server-side for up to 10 turns, and returns one OpenAI-shaped completion (marked with x-gateway-mcp-mode: proxy on the response). That means the same MCP servers work on OpenAI, Gemini, DeepSeek, Groq, Qwen, GLM, and Claude without changing client code.
Three ways to wire MCP into an application
When you want an LLM to call tools exposed by an MCP server (such as a calendar, database, internal API, or GitHub server), you have three architectural choices:
| Dimension | 1. Client-Side MCP Loop | 2. Gateway Passthrough (Claude-Only) | 3. Gateway-Run MCP Loop (Leanroute) |
|---|---|---|---|
| How it works | Your app runs an MCP client, calls tools/list, translates schemas per provider, and runs the while (tool_calls) loop locally. |
Your app sends mcp_servers to the gateway; the gateway forwards the field verbatim to Anthropic's Messages API. |
Your app sends mcp_servers on a standard /v1/chat/completions call (stream: false). The gateway discovers tools, dispatches to any model, executes tool calls, and returns the final answer. |
| Supported models | Whichever providers you write custom schema adapters for. | Anthropic Claude models only. | Any function-calling model in the catalog (GPT, Gemini, DeepSeek, Groq, Qwen, GLM, Kimi, Claude). |
| Tool-call observability | Invisible to the gateway unless you ship separate telemetry. | Opaque: the provider executes the calls outside your gateway logs. | Tools discovered, tool calls, iterations and MCP server failures are returned in x-gateway-mcp-* response headers, and the request is logged with its full cost. |
| Guardrails | You must run PII/secret redaction in your own process. | Apply to the initial prompt only. | Gateway guardrails (PII redaction, secret detection, exfiltration checks) apply to the prompt and to the final answer. Tool results passed between turns are not scanned yet. |
| Server allowlisting | Enforced per app repo; easy to drift across teams. | Rarely enforced at the org edge. | Enforced at the gateway edge via per-org allowlist (/dashboard/mcp) before any connection is opened. |
Why we replaced passthrough with a gateway-run proxy loop
We originally shipped MCP support as a passthrough for Claude models. In production, three problems showed up immediately:
- You couldn't benchmark or fail over to non-Claude models. Teams building agents wanted to test whether
deepseek/deepseek-v4-1-flash($0.15 / $0.60 per 1M tokens) oropenai/gpt-5.6-luna($0.20 / $1.20 per 1M tokens) could run the exact same MCP tool workflow asanthropic/claude-sonnet-4-6($3.00 / $15.00 per 1M tokens) at a fraction of the cost. With passthrough, switching the model string broke the request. - JSON Schema incompatibilities broke cross-provider tool calling. Google Gemini rejects JSON Schemas containing
additionalProperties,$ref,allOf, orconst, and Gemini 3 requires each function call'sthoughtSignatureto be echoed back on subsequent turns. OpenAI reasoning models (gpt-5*,o1*) require specificreasoning_efforthandling when tools are present. - Zero visibility into runaway loops. When an upstream provider ran the MCP loop internally, our gateway couldn't see how many tool calls failed or cap an agent stuck retrying a broken tool.
How Leanroute's gateway-run MCP loop works under the hood
When a non-streaming request (stream: false) arrives at POST https://api.leanroute.dev/v1/chat/completions carrying an mcp_servers array, the gateway executes a deterministic six-step loop:
- Allowlist verification (
403 mcp_server_not_allowlisted): Before opening a socket, the gateway checks every URL inmcp_serversagainst your organization's allowlist at/dashboard/mcp. Unlike a bad model response, a malicious MCP server can exfiltrate prompt data or trigger side effects, so unlisted URLs are rejected at the edge. - Streamable HTTP discovery & namespacing: The gateway connects to each allowlisted MCP server over HTTPS Streamable HTTP and calls
tools/list. Every discovered tool is namespaced asserverName__toolName(for example,calendar__list_eventsandcrm__list_events) so multiple servers never collide, then merged with any localtools[]declarations you sent on the request. - Provider-specific schema normalization: The gateway normalizes the merged function declarations for the target model: rewriting
constto single-valueenumfor Gemini, preservingthought_signatureacross turns, and handling reasoning parameters, so the model never needs native MCP support. - Iterative tool execution (capped at 10 turns): Whenever the model responds with
tool_calls, the gateway intercepts the call, strips theserverName__prefix, executes the tool against the matching MCP server with yourauthorization_token(which is never logged or echoed), and feeds the tool output back into the next model turn. - Freshness guarantee (cache bypass): Because the output of an MCP tool call depends on live external state ("What meetings do I have today?"), requests carrying
mcp_serversautomatically bypass both the exact-match and semantic caches on read and write. - Single OpenAI-shaped response + full header telemetry: Once the model produces a final answer (or hits the 10-iteration ceiling), the gateway returns one standard OpenAI completion. The
usageobject sums input and output tokens across every iteration so the token count you see matches your exact bill.
Try it in one cURL call on GPT, DeepSeek, or Gemini
You don't need an MCP client SDK in your application. Point a standard /v1/chat/completions call at any function-calling model in the Model Catalog:
curl https://api.leanroute.dev/v1/chat/completions \
-H "Authorization: Bearer $LEANROUTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-v4-1-flash",
"stream": false,
"messages": [
{ "role": "user", "content": "Check my calendar MCP server and summarise tomorrow'\''s schedule." }
],
"mcp_servers": [
{
"type": "url",
"url": "https://calendar.example.com/mcp",
"name": "calendar",
"authorization_token": "eyJhbGciOi..."
}
],
"max_tokens": 2048
}'
mcp_servers works on non-streaming requests only. Sending it with "stream": true returns a 400 that says so.