This article is published in English.
Wire MCP Tools Through LiteLLM: Mock Bank Gateway Walkthrough
Register a streamable-HTTP mock bank, compare hand-rolled tool loops to one gateway request, and prove pre_call guardrails never reach MCP.
Photo by Quilia on Unsplash
Assumes a LiteLLM gateway is already up (Admin UI plus database) with gpt-5.4-mini registered against Azure AI Foundry. The overview piece covers that baseline setup.
What “agentic” means in LiteLLM
LiteLLM’s MCP Gateway lets a registered model reach external tools through the proxy. The gateway discovers tools, translates schemas, and runs the execution loop so application code does not have to glue MCP protocol details to each model vendor. Your client talks to one OpenAI-shaped endpoint; the gateway sits between that client, the model, and any MCP servers you register.
Model ids are easy to get wrong. Depending on how the Azure entry was added, LiteLLM may expose azure_ai/gpt-5.4-mini instead of a bare gpt-5.4-mini. Confirm the live registry first:
curl -X GET 'http://localhost:4000/v1/models' -H 'Authorization: Bearer sk-1234'
# Expected Results
{"data":[{"id":"azure_ai/gpt-5.4-mini","object":"model","created":1677610602,"owned_by":"openai"}],"object":"list"}
Use that exact string on every request. A mismatch surfaces as Invalid model name passed in model=... rather than a vague network error.
Same endpoint, plain LLM or with tools
There is no separate “MCP mode” URL. /chat/completions and /v1/responses behave the same for plain chat and tool-augmented chat. The only switch is whether the JSON body includes a tools array (often with type: "mcp" for gateway-managed tools). Omit tools and you get a normal completion even if MCP servers are connected on the gateway. Include tools and the gateway may discover, call, and fold tool results back into the turn.
A plain call without tools looks like this:
curl -X POST 'http://localhost:4000/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-d '{
"model": "azure_ai/gpt-5.4-mini",
"messages": [{"role": "user", "content": "What is the capital of France?"}]
}'
# Expected Results
{"id":"chatcmpl-E778uLVTQJQLIJu0Dc2XAI1UYHuwL","created":1785364460,"model":"azure_ai/gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"The capital of France is **Paris**.","role":"assistant","provider_specific_fields":{"refusal":null},"annotations":[]},"provider_specific_fields":{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}}],"usage":{"completion_tokens":13,"prompt_tokens":13,"total_tokens":26,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"latency_checkpoint":{"engine_tbt_ms":8,"engine_ttft_ms":86,"engine_ttlt_ms":185,"pre_inference_ms":70,"service_tbt_ms":10,"service_ttft_ms":244,"service_ttlt_ms":354,"total_duration_ms":306,"user_visible_ttft_ms":174}},"service_tier":"default","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}]}
A frequent copy-paste failure is an extra quote before the model name (""azure_ai/gpt-5.4-mini"). That yields Invalid JSON payload: unexpected character — a body syntax problem, not a broken route.
The MCP Gateway, step by step (tested end to end)
Mock bank server
Instead of a generic hello-world MCP process, this walkthrough uses a small mock bank with three tools — get_balance, list_transactions, and transfer_funds — backed by two in-memory accounts. Real balances and transfers make it obvious whether the full path worked. Every step below was run and checked against that data.
Step 1 — start the server (see also a standalone mock_bank_server.py):
mkdir mock-bank-server && cd mock-bank-server
uv init .
uv add "mcp[cli]" openai
touch server.py
server.py stays on mocked data, not a real ledger. The intended layout is: client → LiteLLM → guardrails → router → either the Azure model or the mock bank MCP process and its three tools.
A few behavioral details matter once you leave the diagram:
- The interesting path is a loop, not a single arrow. The model asks for a tool; the gateway routes to MCP; the result returns through the gateway; the model continues or asks again. Single-tool turns complete that loop automatically; parallel multi-tool turns may only partially auto-execute on the version tested here.
- Guardrails attach at two places.
pre_callinspects the raw chat payload before routing.pre_mcp_callinspects tool arguments closer to the MCP side. Diagrams often draw one box; the intercepts are different stages. - The dashed “tools” box is a listing inside
server.py, not three separate microservices.
The mock bank MCP server
Roughly ninety lines define FastMCP "mock-bank" with:
get_balance(account_id)— owner plus balancelist_transactions(account_id, limit)— recent rowstransfer_funds(from_account, to_account, amount)— moves funds with basic validation (unknown account, insufficient funds)
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
mcp = FastMCP(
"mock-bank",
host="127.0.0.1",
port=3001,
transport_security=TransportSecuritySettings(
allowed_hosts=["localhost:3001", "127.0.0.1:3001", "host.docker.internal:3001"],
),
)
# --- In-memory mock data (resets every restart) ---
ACCOUNTS = {
"ACC1001": {"owner": "Alice Johnson", "balance": 4250.75, "currency": "USD"},
"ACC1002": {"owner": "Bob Smith", "balance": 980.10, "currency": "USD"},
}
TRANSACTIONS = {
"ACC1001": [
{"date": "2026-07-20", "description": "Grocery Store", "amount": -84.32},
{"date": "2026-07-18", "description": "Payroll Deposit", "amount": 2500.00},
{"date": "2026-07-15", "description": "Electric Bill", "amount": -120.44},
],
"ACC1002": [
{"date": "2026-07-21", "description": "Coffee Shop", "amount": -6.75},
{"date": "2026-07-19", "description": "Freelance Payment", "amount": 450.00},
],
}
@mcp.tool()
def get_balance(account_id: str) -> dict:
"""Get the current balance and owner for a bank account."""
account = ACCOUNTS.get(account_id)
if not account:
return {"error": f"Account '{account_id}' not found"}
return {
"account_id": account_id,
"owner": account["owner"],
"balance": account["balance"],
"currency": account["currency"],
}
@mcp.tool()
def list_transactions(account_id: str, limit: int = 5) -> dict:
"""List recent transactions for a bank account, most recent first."""
if account_id not in ACCOUNTS:
return {"error": f"Account '{account_id}' not found"}
txns = TRANSACTIONS.get(account_id, [])[:limit]
return {"account_id": account_id, "transactions": txns}
@mcp.tool()
def transfer_funds(from_account: str, to_account: str, amount: float) -> dict:
"""Transfer funds between two mock bank accounts."""
if from_account not in ACCOUNTS:
return {"error": f"Source account '{from_account}' not found"}
if to_account not in ACCOUNTS:
return {"error": f"Destination account '{to_account}' not found"}
if amount <= 0:
return {"error": "Transfer amount must be positive"}
if ACCOUNTS[from_account]["balance"] < amount:
return {"error": f"Insufficient funds in '{from_account}'"}
ACCOUNTS[from_account]["balance"] -= amount
ACCOUNTS[to_account]["balance"] += amount
return {
"status": "success",
"from_account": from_account,
"to_account": to_account,
"amount": amount,
"new_balance_from": ACCOUNTS[from_account]["balance"],
"new_balance_to": ACCOUNTS[to_account]["balance"],
}
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Comparison scripts clarify what the gateway replaces:
test-direct-mcp.pyspeaks only to the MCP server — proves tools work, never involves a model.test-direct-model.pycalls Azure and MCP directly and manually orchestrates schema translation, tool execution, and the second turn — about seventy-five lines of glue the gateway collapses into atoolsarray on one HTTP call.- Neither script hits port
4000; that isolation is the point of the before/after comparison.
Boot the server and leave it running:
uv run python server.py
# Execution results
INFO: Started server process [91854]
INFO: Waiting for application startup.
[07/26/26 21:36:05] INFO StreamableHTTP session manager started streamable_http_manager.py:131
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:3001 (Press CTRL+C to quit)
[07/26/26 21:36:26] INFO Created new transport with session ID: caf5347483ba4256977e29de8892a327
Step 2 — two reachability fixes before LiteLLM (in Docker) can talk to the host process
Inside the Compose/Admin UI container, localhost is the container, not your laptop. Point the MCP URL at Docker’s host-forward DNS:
http://host.docker.internal:3001/mcp
Separately, the MCP SDK’s DNS-rebinding protection can answer 421 Misdirected Request unless the forwarded Host is allowlisted on the server:
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
mcp = FastMCP(
"mock-bank",
host="127.0.0.1",
port=3001,
transport_security=TransportSecuritySettings(
allowed_hosts=["localhost:3001", "127.0.0.1:3001", "host.docker.internal:3001"],
),
)
Step 3 — register the server with LiteLLM
In the UI: MCP Servers → Add MCP Server
- Name:
mock_bank - Transport: Streamable HTTP (must match
mcp.run(transport="streamable-http")) - URL:
http://host.docker.internal:3001/mcp - Authentication: none for this local demo
Or via config:
mcp_servers:
mock_bank:
url: http://host.docker.internal:3001/mcp
transport: streamable_http
auth_type: none
Confirm Connection Status: Connected and that Tool Configuration lists all three tools enabled.
Step 4 — confirm the model string the gateway actually registered
curl -X GET 'http://localhost:4000/v1/models' -H 'Authorization: Bearer sk-1234'
# Expected Result
{"data":[{"id":"azure_ai/gpt-5.4-mini","object":"model","created":1677610602,"owned_by":"openai"}],"object":"list"}
Despite the azure_ai/ prefix, traffic still hit the Azure OpenAI chat pipeline in testing (content-filter fields appeared on responses). Treat the id as a naming quirk to copy verbatim, not as a wrong provider.
Step 5 — single-tool turn (clean path)
curl --location 'http://localhost:4000/v1/responses' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer sk-1234" \
--data '{
"model": "azure_ai/gpt-5.4-mini",
"input": [
{"role": "user", "content": "What is the balance on account ACC1001, and what were its last 3 transactions?", "type": "message"}
],
"tools": [
{
"type": "mcp",
"server_label": "mock_bank",
"server_url": "litellm_proxy",
"require_approval": "never"
}
],
"tool_choice": "required"
}'
# Expected Result
{"id":"resp_QQRbArl1kNuzmPJahCQIWJvpgxSgsw7SuuwX_pZJfuWmrGiguydiPvF7EhPhfRrojCr6UtQGUTlWEA3FSDBl4c1PSDpccSmbIZWgCMizU1Vp8gDt_jokT8ZOL9tIiYK0pUHjYVGu3PgbJjz5J0jkAJUFKshh_-9xn0zVGf08-33WD19mcLCh3qfxRM4AcMciWE4nNQa_TrS-6U6olfonnXvge9qszI4u6dA02Eo6GlLmEpGE19InxbopOLBny1QNdMDgdC4SzzZh9XWnQvFNu-IjYEe68pfoJ5u0ohZE1scbgF9ABNMAaN94vfGIuApDd9ibCBpgbHbcTsrE86EGP6_XJ_72uwG_aTHTjgZ-laFUxCIjEgEEo8S09Ojq-eupjWI8WTwSOT_ozMKVF0cyOuQKwkpARIrIeGE=","created_at":1785242019,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"azure_ai/gpt-5.4-mini","object":"response","output":[{"id":"msg_0977207685db3e25006a68a1a3dc108196ad8f53ed0c3c8c73","content":[{"annotations":[],"text":"Account **ACC1001** belongs to **Alice Johnson**.\n\n- **Balance:** **$3,450.75 USD**\n\nLast 3 transactions:\n1. **2026-07-20** — Grocery Store — **-$84.32**\n2. **2026-07-18** — Payroll Deposit — **+$2,500.00**\n3. **2026-07-15** — Electric Bill — **-$120.44**","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message","phase":"final_answer"},{"type":"mcp_tools_fetched","id":"mcp_tools_cbeff22f","status":"completed","role":"system","content":[{"type":"output_text","text":"[\n \"name='mock_bank-get_balance' title=None description='Get the current balance and owner for a bank account.' inputSchema={'properties': {'account_id': {'title': 'Account Id', 'type': 'string'}}, 'required': ['account_id'], 'title': 'get_balanceArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\",\n \"name='mock_bank-list_transactions' title=None description='List recent transactions for a bank account, most recent first.' inputSchema={'properties': {'account_id': {'title': 'Account Id', 'type': 'string'}, 'limit': {'default': 5, 'title': 'Limit', 'type': 'integer'}}, 'required': ['account_id'], 'title': 'list_transactionsArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\",\n \"name='mock_bank-transfer_funds' title=None description='Transfer funds between two mock bank accounts.' inputSchema={'properties': {'from_account': {'title': 'From Account', 'type': 'string'}, 'to_account': {'title': 'To Account', 'type': 'string'}, 'amount': {'title': 'Amount', 'type': 'number'}}, 'required': ['from_account', 'to_account', 'amount'], 'title': 'transfer_fundsArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\"\n]","annotations":[]}],"phase":null},{"type":"tool_execution_results","id":"tool_results_ee9f6c7e","status":"completed","role":"system","content":[{"type":"output_text","text":"[\n {\n \"tool_call_id\": \"call_ToKnEWo532C8nDABKElHCSBs\",\n \"result\": \"{\\n \\\"account_id\\\": \\\"ACC1001\\\",\\n \\\"owner\\\": \\\"Alice Johnson\\\",\\n \\\"balance\\\": 3450.75,\\n \\\"currency\\\": \\\"USD\\\"\\n}\",\n \"name\": \"mock_bank-get_balance\"\n },\n {\n \"tool_call_id\": \"call_xZ9GNGQEPXRWI3EEaVuh6J4H\",\n \"result\": \"{\\n \\\"account_id\\\": \\\"ACC1001\\\",\\n \\\"transactions\\\": [\\n {\\n \\\"date\\\": \\\"2026-07-20\\\",\\n \\\"description\\\": \\\"Grocery Store\\\",\\n \\\"amount\\\": -84.32\\n },\\n {\\n \\\"date\\\": \\\"2026-07-18\\\",\\n \\\"description\\\": \\\"Payroll Deposit\\\",\\n \\\"amount\\\": 2500.0\\n },\\n {\\n \\\"date\\\": \\\"2026-07-15\\\",\\n \\\"description\\\": \\\"Electric Bill\\\",\\n \\\"amount\\\": -120.44\\n }\\n ]\\n}\",\n \"name\": \"mock_bank-list_transactions\"\n }\n]","annotations":[]}],"phase":null}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"mock_bank-get_balance","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"Get the current balance and owner for a bank account."},{"name":"mock_bank-list_transactions","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"List recent transactions for a bank account, most recent first."},{"name":"mock_bank-transfer_funds","parameters":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"Transfer funds between two mock bank accounts."}],"top_p":0.98,"max_output_tokens":null,"previous_response_id":"resp_0977207685db3e25006a68a1a1f48c8196b7990872db041d14","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"status":"completed","text":{"format":{"type":"text"},"verbosity":"medium"},"truncation":"disabled","usage":{"input_tokens":473,"input_tokens_details":{"audio_tokens":null,"cached_tokens":0,"text_tokens":null},"output_tokens":97,"output_tokens_details":{"reasoning_tokens":0,"text_tokens":null},"total_tokens":570,"cost":null},"user":null,"store":true,"background":false,"completed_at":1785242020,"content_filters":[{"blocked":false,"source_type":"completion","content_filter_raw":[],"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"hate":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"}},"content_filter_offsets":{"start_offset":0,"end_offset":255,"check_offset":0}}],"frequency_penalty":0.0,"max_tool_calls":null,"moderation":null,"presence_penalty":0.0,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","safety_identifier":null,"service_tier":"default","top_logprobs":0
In the successful run, the model called get_balance and list_transactions, then answered with live mock data — Alice Johnson on ACC1001, balance matching the in-memory fixture, and the three recent transactions from mock_bank_server.py. That confirms gateway → discovery → execution → final answer.
Step 6 — state-changing call when it is the only tool in the turn
curl -X POST 'http://localhost:4000/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-d '{
"model": "azure_ai/gpt-5.4-mini",
"messages": [{"role": "user", "content": "First, transfer $200 from ACC1001 to ACC1002. Do not call any other tools yet."}],
"tools": [{"type": "mcp", "server_label": "mock_bank", "server_url": "litellm_proxy", "require_approval": "never"}],
"tool_choice": "auto"
}'
# Expected result
{"id":"chatcmpl-E6bKV9nPurWICiuBlkgwaJzOS8vIC","created":1785242171,"model":"azure_ai/gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"Done — transferred $200 from ACC1001 to ACC1002 successfully.","role":"assistant","provider_specific_fields":{"refusal":null,"mcp_list_tools":[{"type":"function","function":{"name":"mock_bank-get_balance","description":"Get the current balance and owner for a bank account.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-list_transactions","description":"List recent transactions for a bank account, most recent first.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-transfer_funds","description":"Transfer funds between two mock bank accounts.","parameters":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object","additionalProperties":false},"strict":false}}],"mcp_tool_calls":[{"function":{"arguments":"{\"from_account\":\"ACC1001\",\"to_account\":\"ACC1002\",\"amount\":200}","name":"mock_bank-transfer_funds"},"id":"call_PIN38jt2KT9M4hkoxEKXXXaD","type":"function"}],"mcp_call_results":[{"tool_call_id":"call_PIN38jt2KT9M4hkoxEKXXXaD","result":"{\n \"status\": \"success\",\n \"from_account\": \"ACC1001\",\n \"to_account\": \"ACC1002\",\n \"amount\": 200.0,\n \"new_balance_from\": 3250.75,\n \"new_balance_to\": 1980.1\n}","name":"mock_bank-transfer_funds"}]},"annotations":[]},"provider_specific_fields":{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}}],"usage":{"completion_tokens":19,"prompt_tokens":373,"total_tokens":392,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"latency_checkpoint":{"engine_tbt_ms":5,"engine_ttft_ms":30,"engine_ttlt_ms":116,"pre_inference_ms":120,"service_tbt_ms":5,"service_ttft_ms":306,"service_ttlt_ms":316,"total_duration_ms":223,"user_visible_ttft_ms":186}},"service_tier":"default","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}]}
Result: finish_reason: "stop", transfer_funds auto-executed, final summary with both updated balances. Moving money is exactly the class of action that deserves guardrails before anything leaves a laptop demo.
Lesson — parallel tool calls in one turn
Compound prompts such as “transfer $200 from ACC1001 to ACC1002, then confirm both balances” caused the model to emit three tool calls at once. On the LiteLLM build under test, MCP auto-execution reliably finished only one (transfer_funds showed in mcp_tool_calls / mcp_call_results) while the leftover get_balance entries sat unresolved in the OpenAI-shaped tool_calls array — with no final text, because the model was still waiting. The behavior showed up on both /v1/responses and /chat/completions, and with tool_choice set to either "required" or "auto". So it is not a request-shape quirk; it is how that version handled multiple parallel MCP tool calls in a single turn.
Practical guidance for that build: prompt for one tool at a time (as in Step 6). If you truly need multi-call parallelism, complete the loop yourself — execute leftover tool_calls and POST role: "tool" results, the same pattern shown in the Direct vs Gateway scripts below. Model choice still matters for agentic reliability; yesterday’s “perfect” pick can look dated by the next release cycle, so keep the orchestration path swappable.
Step 7 — verify discovery without involving the model
curl -X POST 'http://localhost:4000/mcp/mock_bank' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Expected result
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"mock_bank-get_balance","description":"Get the current balance and owner for a bank account.","inputSchema":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object"}},{"name":"mock_bank-list_transactions","description":"List recent transactions for a bank account, most recent first.","inputSchema":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object"}},{"name":"mock_bank-transfer_funds","description":"Transfer funds between two mock bank accounts.","inputSchema":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object"}}]}}
That call separates “discovery is broken” from “the model declined to call the tool” or “only part of the turn executed.”
Direct vs Gateway: what the proxy buys you
Test 1 — raw MCP, no model, no gateway. Prove the bank server alone:
import asyncio
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
async def main():
async with streamablehttp_client("http://127.0.0.1:3001/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# 1. Discover what tools exist
tools = await session.list_tools()
print("Available tools:", [t.name for t in tools.tools])
# 2. Call a tool directly — YOU decide which tool and args.
# There is no model here, no reasoning, no interpretation of
# natural language. You must already know exactly what to call.
result = await session.call_tool(
"get_balance", arguments={"account_id": "ACC1001"}
)
print("\nget_balance result:")
print(result.content[0].text)
result2 = await session.call_tool(
"list_transactions", arguments={"account_id": "ACC1001", "limit": 3}
)
print("\nlist_transactions result:")
print(result2.content[0].text)
if __name__ == "__main__":
asyncio.run(main())
Expected listing and balance output:
uv run test-direct-mcp.py
# Expected result
Available tools: ['get_balance', 'list_transactions', 'transfer_funds']
get_balance result:
{
"account_id": "ACC1001",
"owner": "Alice Johnson",
"balance": 3250.75,
"currency": "USD"
}
list_transactions result:
{
"account_id": "ACC1001",
"transactions": [
{
"date": "2026-07-20",
"description": "Grocery Store",
"amount": -84.32
},
{
"date": "2026-07-18",
"description": "Payroll Deposit",
"amount": 2500.0
},
{
"date": "2026-07-15",
"description": "Electric Bill",
"amount": -120.44
}
]
}
Test 2 — direct model plus hand-rolled orchestration. Fetch MCP tools, translate schemas to OpenAI tool format, call Azure, execute tools, feed results back — roughly seventy-five lines your app must own, with no shared guardrails or spend tracking:
import asyncio
import json
import os
from openai import AzureOpenAI
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
MODEL_DEPLOYMENT = "gpt-5.4-mini"
def mcp_tool_to_openai_format(mcp_tool):
"""Manual translation step #2 — the gateway normally does this for you."""
return {
"type": "function",
"function": {
"name": mcp_tool.name,
"description": mcp_tool.description or "",
"parameters": mcp_tool.inputSchema,
},
}
async def main():
client = AzureOpenAI(
api_key=os.environ["AZURE_API_KEY"],
api_version="2024-10-21",
azure_endpoint=os.environ["AZURE_API_BASE"],
)
async with streamablehttp_client("http://127.0.0.1:3001/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# Step 1: discover tools yourself
mcp_tools = (await session.list_tools()).tools
openai_tools = [mcp_tool_to_openai_format(t) for t in mcp_tools]
messages = [
{
"role": "user",
"content": "What is the balance on account ACC1001, and "
"what were its last 3 transactions?",
}
]
# Step 3: call the model directly
response = client.chat.completions.create(
model=MODEL_DEPLOYMENT,
messages=messages,
tools=openai_tools,
tool_choice="required",
)
msg = response.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
# Step 4: manually execute any tool calls the model requested
for tool_call in msg.tool_calls or []:
args = json.loads(tool_call.function.arguments)
result = await session.call_tool(tool_call.function.name, arguments=args)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result.content[0].text,
}
)
# Second turn: give the model the tool results, get final answer
final = client.chat.completions.create(
model=MODEL_DEPLOYMENT,
messages=messages,
)
print(final.choices[0].message.content)
if __name__ == "__main__":
asyncio.run(main())
Sample successful narrative answer:
uv run test-direct-model.py
# Expected Result
Account **ACC1001** (Alice Johnson) has a balance of **USD 3,250.75**.
Last 3 transactions:
1. **2026-07-20** — Grocery Store — **-84.32**
2. **2026-07-18** — Payroll Deposit — **+2,500.00**
3. **2026-07-15** — Electric Bill — **-120.44**
Test 3 — same intent through LiteLLM. One HTTP request; discovery, translation, execution, and the follow-up turn live inside the gateway:
curl --location 'http://localhost:4000/v1/responses' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer sk-1234" \
--data '{
"model": "azure_ai/gpt-5.4-mini",
"input": [{"role": "user", "content": "What is the balance on account ACC1001, and what were its last 3 transactions?", "type": "message"}],
"tools": [{"type": "mcp", "server_label": "mock_bank", "server_url": "litellm_proxy", "require_approval": "never"}],
"tool_choice": "required"
}'
# Expected Result
{"id":"resp_DCjguhF87Zw3ekO8Jw3782IkYnZQfG_avgYD_kkI-NmeDU9bNSDRFM3FTGJl31tMzutajvYJ6x_S8siU06zyXozMCPC1pSQhXraDtpREE_RRXq23BZDJR37fxmPqzVsdhlk-o2IEF3C8Z3251nJPIecUJM75GFAYdXygfMfoqmM1Jdaxc3VQfcZKMaW1IOG7528zrRqms58F5xwpR0vvFScwclLhOYrch5WQRY6KBBi-60wMBuylfQNfqT8cWwvbeG7Xbe6OKEiBXFzWOx7QkaGhckDFat-DZ3RFW8Iv8SP0a21rA_9h19QUijB-sPBaD9BNIKk4I2dRqz2B1vMtyut3GpX8mO5D5dinKruIMy2nrMatBun8uJC5Mt76hWgFc6YzH3h_ihsZjCRzWz1j85VowrVdnOYGGhg=","created_at":1785242960,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"azure_ai/gpt-5.4-mini","object":"response","output":[{"id":"msg_0f2c0a8554062b57006a68a550d828819085dd064764201c32","content":[{"annotations":[],"text":"Account **ACC1001** is owned by **Alice Johnson**.\n\n- **Balance:** **$3,250.75 USD**\n\n**Last 3 transactions:**\n1. **2026-07-20** — Grocery Store — **-$84.32**\n2. **2026-07-18** — Payroll Deposit — **+$2,500.00**\n3. **2026-07-15** — Electric Bill — **-$120.44**","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message","phase":"final_answer"},{"type":"mcp_tools_fetched","id":"mcp_tools_9d9b1cdc","status":"completed","role":"system","content":[{"type":"output_text","text":"[\n \"name='mock_bank-get_balance' title=None description='Get the current balance and owner for a bank account.' inputSchema={'properties': {'account_id': {'title': 'Account Id', 'type': 'string'}}, 'required': ['account_id'], 'title': 'get_balanceArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\",\n \"name='mock_bank-list_transactions' title=None description='List recent transactions for a bank account, most recent first.' inputSchema={'properties': {'account_id': {'title': 'Account Id', 'type': 'string'}, 'limit': {'default': 5, 'title': 'Limit', 'type': 'integer'}}, 'required': ['account_id'], 'title': 'list_transactionsArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\",\n \"name='mock_bank-transfer_funds' title=None description='Transfer funds between two mock bank accounts.' inputSchema={'properties': {'from_account': {'title': 'From Account', 'type': 'string'}, 'to_account': {'title': 'To Account', 'type': 'string'}, 'amount': {'title': 'Amount', 'type': 'number'}}, 'required': ['from_account', 'to_account', 'amount'], 'title': 'transfer_fundsArguments', 'type': 'object'} outputSchema=None icons=None annotations=None meta=None execution=None\"\n]","annotations":[]}],"phase":null},{"type":"tool_execution_results","id":"tool_results_56298345","status":"completed","role":"system","content":[{"type":"output_text","text":"[\n {\n \"tool_call_id\": \"call_t6y64QgQjDT4jGMLOEyFxc5u\",\n \"result\": \"{\\n \\\"account_id\\\": \\\"ACC1001\\\",\\n \\\"owner\\\": \\\"Alice Johnson\\\",\\n \\\"balance\\\": 3250.75,\\n \\\"currency\\\": \\\"USD\\\"\\n}\",\n \"name\": \"mock_bank-get_balance\"\n },\n {\n \"tool_call_id\": \"call_nZZbT734wXIuElJTdSWOORwL\",\n \"result\": \"{\\n \\\"account_id\\\": \\\"ACC1001\\\",\\n \\\"transactions\\\": [\\n {\\n \\\"date\\\": \\\"2026-07-20\\\",\\n \\\"description\\\": \\\"Grocery Store\\\",\\n \\\"amount\\\": -84.32\\n },\\n {\\n \\\"date\\\": \\\"2026-07-18\\\",\\n \\\"description\\\": \\\"Payroll Deposit\\\",\\n \\\"amount\\\": 2500.0\\n },\\n {\\n \\\"date\\\": \\\"2026-07-15\\\",\\n \\\"description\\\": \\\"Electric Bill\\\",\\n \\\"amount\\\": -120.44\\n }\\n ]\\n}\",\n \"name\": \"mock_bank-list_transactions\"\n }\n]","annotations":[]}],"phase":null}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","tools":[{"name":"mock_bank-get_balance","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"Get the current balance and owner for a bank account."},{"name":"mock_bank-list_transactions","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"List recent transactions for a bank account, most recent first."},{"name":"mock_bank-transfer_funds","parameters":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object","additionalProperties":false},"strict":false,"type":"function","defer_loading":null,"description":"Transfer funds between two mock bank accounts."}],"top_p":0.98,"max_output_tokens":null,"previous_response_id":"resp_0f2c0a8554062b57006a68a54eeaa0819097e6e8dcb9255a38","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"status":"completed","text":{"format":{"type":"text"},"verbosity":"medium"},"truncation":"disabled","usage":{"input_tokens":473,"input_tokens_details":{"audio_tokens":null,"cached_tokens":0,"text_tokens":null},"output_tokens":100,"output_tokens_details":{"reasoning_tokens":0,"text_tokens":null},"total_tokens":573,"cost":null},"user":null,"store":true,"background":false,"completed_at":1785242961,"content_filters":[{"blocked":false,"source_type":"completion","content_filter_raw":[],"content_filter_results":{"protected_material_text":{"detected":false,"filtered":false},"protected_material_code":{"detected":false,"filtered":false},"hate":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"}},"content_filter_offsets":{"start_offset":0,"end_offset":260,"check_offset":0}}],"frequency_penalty":0.0,"max_tool_calls":null,"moderation":null,"presence_penalty":0.0,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","safety_identifier":null,"service_tier":"default","top_logprobs":0}
| Concern | Direct (Test 2) | Gateway (Test 3) |
|---|---|---|
| Application code | ~75 lines per app | One HTTP request |
| Schema translation | Manual per MCP server | Automatic |
| Tool loop | You maintain it | Internal |
| Provider swap | Rewrite SDK calls | Change the model string |
| Auth to many MCP servers | Custom per server | Central mcp_servers config |
| Guardrails / spend | Build yourself | Available when configured |
Guardrails such as email redaction, card-number blocks, and keyword blocks run in pre_call — before MCP discovery or execution. A blocked request should never touch the mock bank. That property is what worth proving, not only that text looks masked in the response.
curl -X POST 'http://localhost:4000/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-d '{
"model": "azure_ai/gpt-5.4-mini",
"messages": [{"role": "user", "content": "Check the balance on ACC1001 and email the results to john.doe@example.com"}],
"tools": [{"type": "mcp", "server_label": "mock_bank", "server_url": "litellm_proxy", "require_approval": "never"}],
"tool_choice": "auto",
"guardrails": ["basic-content-filter"]
}'
# Expected result
{"id":"chatcmpl-E6xkAAMouHdsDuJR038UZH4Thej9z","created":1785328330,"model":"azure_ai/gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"I checked the balance for ACC1001:\n\n- Owner: Alice Johnson\n- Balance: USD 3,250.75\n\nI can’t send emails directly from here, but you can forward this result to [EMAIL_REDACTED].","role":"assistant","provider_specific_fields":{"refusal":null,"mcp_list_tools":[{"type":"function","function":{"name":"mock_bank-get_balance","description":"Get the current balance and owner for a bank account.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-list_transactions","description":"List recent transactions for a bank account, most recent first.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-transfer_funds","description":"Transfer funds between two mock bank accounts.","parameters":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object","additionalProperties":false},"strict":false}}],"mcp_tool_calls":[{"function":{"arguments":"{\"account_id\":\"ACC1001\"}","name":"mock_bank-get_balance"},"id":"call_4jiRhEZhMUSY8GLWJHfDWulI","type":"function"}],"mcp_call_results":[{"tool_call_id":"call_4jiRhEZhMUSY8GLWJHfDWulI","result":"{\n \"account_id\": \"ACC1001\",\n \"owner\": \"Alice Johnson\",\n \"balance\": 3250.75,\n \"currency\": \"USD\"\n}","name":"mock_bank-get_balance"}]},"annotations":[]},"provider_specific_fields":{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}}],"usage":{"completion_tokens":53,"prompt_tokens":332,"total_tokens":385,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"latency_checkpoint":{"engine_tbt_ms":4,"engine_ttft_ms":36,"engine_ttlt_ms":260,"pre_inference_ms":125,"service_tbt_ms":4,"service_ttft_ms":375,"service_ttlt_ms":594,"total_duration_ms":478,"user_visible_ttft_ms":250}},"service_tier":"default","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}]}
Repeat without naming guardrails in the user text:
curl -X POST 'http://localhost:4000/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-d '{
"model": "azure_ai/gpt-5.4-mini",
"messages": [{"role": "user", "content": "Check the balance on ACC1001 and email the results to john.doe@example.com"}],
"tools": [{"type": "mcp", "server_label": "mock_bank", "server_url": "litellm_proxy", "require_approval": "never"}],
"tool_choice": "auto"
}'
# Expected result
{"id":"chatcmpl-E6xlbLbaUP1awks92ZGSrsHCB8sl0","created":1785328419,"model":"azure_ai/gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"I checked ACC1001:\n\n- Owner: Alice Johnson\n- Balance: $3,250.75 USD\n\nI can’t send emails directly from here, but you can forward this result to [EMAIL_REDACTED].","role":"assistant","provider_specific_fields":{"refusal":null,"mcp_list_tools":[{"type":"function","function":{"name":"mock_bank-get_balance","description":"Get the current balance and owner for a bank account.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"}},"required":["account_id"],"title":"get_balanceArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-list_transactions","description":"List recent transactions for a bank account, most recent first.","parameters":{"properties":{"account_id":{"title":"Account Id","type":"string"},"limit":{"default":5,"title":"Limit","type":"integer"}},"required":["account_id"],"title":"list_transactionsArguments","type":"object","additionalProperties":false},"strict":false}},{"type":"function","function":{"name":"mock_bank-transfer_funds","description":"Transfer funds between two mock bank accounts.","parameters":{"properties":{"from_account":{"title":"From Account","type":"string"},"to_account":{"title":"To Account","type":"string"},"amount":{"title":"Amount","type":"number"}},"required":["from_account","to_account","amount"],"title":"transfer_fundsArguments","type":"object","additionalProperties":false},"strict":false}}],"mcp_tool_calls":[{"function":{"arguments":"{\"account_id\":\"ACC1001\"}","name":"mock_bank-get_balance"},"id":"call_59tE386FwTOGOROXJNJPQZlB","type":"function"}],"mcp_call_results":[{"tool_call_id":"call_59tE386FwTOGOROXJNJPQZlB","result":"{\n \"account_id\": \"ACC1001\",\n \"owner\": \"Alice Johnson\",\n \"balance\": 3250.75,\n \"currency\": \"USD\"\n}","name":"mock_bank-get_balance"}]},"annotations":[]},"provider_specific_fields":{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}}],"usage":{"completion_tokens":50,"prompt_tokens":332,"total_tokens":382,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"latency_checkpoint":{"engine_tbt_ms":5,"engine_ttft_ms":39,"engine_ttlt_ms":272,"pre_inference_ms":154,"service_tbt_ms":5,"service_ttft_ms":434,"service_ttlt_ms":646,"total_duration_ms":507,"user_visible_ttft_ms":281}},"service_tier":"default","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}]}
Both should show emails REDACTED when guardrails are enabled by default. If they are off, turn them on in the Admin UI.
Practical explanations
1. Must every request pass tools?
Yes, whenever that turn should use MCP. The gateway does not silently attach tools to calls that omit them. Omit tools and you get a plain LLM completion even if mock_bank is connected.
2. tool_choice: "required" vs "auto"
Use "required" for a single guaranteed tool-call smoke test. Prefer "auto" for conversational or multi-step flows, because "required" structurally cannot emit a final natural-language answer on its own.
3. Safer, clearer tool selection
Customers otherwise need to know exact tool names and schemas, and nothing stops a risky tool like transfer_funds from firing as easily as get_balance. LiteLLM already offers levers:
a) Publish the catalog — do not make integrators reverse-engineer model tool calls:
curl -X POST 'http://localhost:4000/mcp/mock_bank' \
-H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' \
-d '{"method":"tools/list"}'
Expose this (or /v1/mcp/tools) in developer docs or an onboarding UI so names, descriptions, and argument schemas are visible before anyone writes client code.
b) Prefer per-key / per-team MCP permissions over allow_all_keys. Scope mock_bank through Guardrails/MCP → Permission Management so each key only sees the tools it should.
c) Split by risk. Mark transfer_funds as a write/risky tool and set require_approval: "always" so a human confirms before money moves. Leave read tools on "never" if that matches your threat model.
d) Enforce with the server-side Tool Permission Guardrail, not client tool_choice. Callers control tool_choice; it is not a security boundary. A gateway guardrail cannot be skipped by a customer app:
guardrails:
- guardrail_name: "mcp-tool-permissions"
litellm_params:
guardrail: tool_permission
mode: "pre_mcp_call"
rules:
- rule_id: "block-transfers"
tool_name: "^mock_bank-transfer_fundsquot;
decision: "deny"
default_action: "allow"
e) Restrict arguments, not only tool allowlists. allowed_params can cap amount ranges or limit which account_id values a key may touch — finer than an all-or-nothing deny.
Treat the tool catalog like a product API: publish it, scope it per customer, and risk-tier read vs write with server-side policy rather than trusting every client.
What next
With basic guardrails and MCP through LiteLLM in place, natural follow-ons are rate limits, max concurrent requests, MCP-specific input guards, and tighter tool-selection policy. Until those are wired, keep demos local, keep risky tools behind approval, and stay deliberate about what each API key can invoke.
When debugging flaky agentic turns, log the gateway request id, the registered model string, which MCP server names were attached, and whether tool_choice was auto or required. That quartet usually separates “wrong model id,” “tools omitted,” “discovery down,” and “parallel-call partial execution” faster than re-running curls by hand. Keep the mock bank disposable: wipe and restart between transfer tests so leftover balances do not masquerade as successful new demos.