MCP vs OpenAI function calling is usually framed as a choice, as if a team must pick one way for models to use tools. That framing causes real design mistakes. The two operate at different layers. Function calling is how a model tells the application that called it which tool to run and with what arguments. The Model Context Protocol is how an application discovers and invokes tools that live in separate servers. Most serious systems use both: the model emits a function call, and the application fulfils it by sending an MCP request.
This article shows both message formats, compares them dimension by dimension, and then builds the adapter that connects them, including why many MCP schemas break OpenAI strict mode and what to do about it. It then covers the third option, the Responses API's hosted mcp tool, where the model platform itself acts as the MCP client. MCP details follow specification revision 2025-11-25; OpenAI details follow the function calling and MCP guides on developers.openai.com as read on 2026-10-01. How to write tool definitions models use well is covered in function calling in depth.
Two layers, one loop
Function calling is a feature of a model API. You send the model a list of tool definitions along with the conversation; the model may answer with a request to call one of them; your code runs the function and sends the result back in the next request; the model continues. The model never executes anything. Where the function lives, how it is discovered and who is allowed to call it are entirely your application's business.
MCP is a protocol between a host application (through one MCP client per connection) and MCP servers. A server advertises tools, resources and prompts; a client lists them and invokes them over JSON-RPC, using stdio for local processes or Streamable HTTP for remote ones. MCP says nothing about which model is used or how the model asks for a tool. The two meet in the host: it converts MCP tool definitions into whatever format its model API expects, and converts the model's tool requests into MCP calls.
Function calling, concretely
In the Responses API a function tool is an object with type set to function, a name, a description, a JSON Schema in parameters, and an optional strict flag. When the model decides to call it, the response output contains a function_call item with a call_id, the name and the arguments as a JSON-encoded string. Your code parses the arguments, runs the function and sends a function_call_output item with the same call_id and the result as output.
tools = [{
"type": "function",
"name": "get_ticket",
"description": "Fetch a support ticket by its numeric id.",
"parameters": {
"type": "object",
"properties": {"ticket_id": {"type": "integer"}},
"required": ["ticket_id"],
"additionalProperties": False,
},
"strict": True,
}]
# Model output item
{"type": "function_call", "call_id": "call_abc", "name": "get_ticket",
"arguments": "{\"ticket_id\": 4812}"}
# What your app sends back on the next request
{"type": "function_call_output", "call_id": "call_abc",
"output": "{\"status\": \"open\", \"priority\": \"high\"}"}Two controls shape behaviour. tool_choice can be auto (the default: zero, one or several calls), required, none, a specific function, or an allowed_tools subset. parallel_tool_calls set to false restricts the model to at most one call per turn, which matters when calls have ordering constraints. Strict mode constrains the model's arguments to the schema exactly, with requirements covered below.
MCP tools, concretely
An MCP server that offers tools declares the tools capability during initialisation, optionally with listChanged set to true. A client sends tools/list, which is paginated with a cursor and nextCursor, and receives tool definitions with a name, an optional title for display, a description, an inputSchema, and optionally an outputSchema and annotations. Invocation is a tools/call request with the tool name and an arguments object.
// tools/call request
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "get_ticket", "arguments": {"ticket_id": 4812}}}
// result: unstructured content, optional structured content, error flag
{"jsonrpc": "2.0", "id": 7, "result": {
"content": [{"type": "text", "text": "{\"status\": \"open\", \"priority\": \"high\"}"}],
"structuredContent": {"status": "open", "priority": "high"},
"isError": false}}MCP separates two kinds of error. Protocol errors, such as an unknown tool or a malformed request, are JSON-RPC errors. Tool execution errors, such as an upstream API failure or an out-of-range argument, are returned as a normal result with isError set to true, and the specification says clients should pass these to the model so it can correct itself. Results can carry text, images, audio, resource links and embedded resources, and when an outputSchema is declared the server must return conforming structuredContent. Tool design on the server side is covered in MCP tools architecture.
Side by side
| Dimension | OpenAI function calling | MCP tools |
|---|---|---|
| Layer | Model to application | Application (host) to tool server |
| Who defines tools | Your app, per request | The server, discovered at runtime |
| Who executes | Your app | The server process |
| Discovery | None; you send the list | tools/list with pagination; list_changed notifications |
| Input schema | JSON Schema in parameters; strict subset when strict | JSON Schema in inputSchema, 2020-12 by default |
| Output | A string or content you choose | content items, optional structuredContent with outputSchema |
| Errors | Whatever you put in the output | JSON-RPC protocol errors vs isError results |
| Transport | HTTPS to the model API | stdio or Streamable HTTP, JSON-RPC 2.0 |
| Reuse across models | Format is provider-specific | Same server works with any host |
The last row is the practical reason MCP exists. Without it, every application re-implements every integration for every model provider. With it, a ticketing integration is written once as a server, and any host, using any model, can list and call its tools.
The adapter: bridging MCP to function calling
In pattern B the host runs an adapter with two jobs. On the way in, it converts each MCP server's tool list into function tools, namespacing names so tools from different servers cannot collide, and keeps a routing table back to the right server and original name. On the way out, it turns each function_call into a tools/call on the correct client and converts the result into a function_call_output. The sketch below assumes an MCP client object with list_tools and call_tool coroutines that return the JSON shown above; adapt it to whichever SDK you use.
import json
class Bridge:
def __init__(self, clients): # {"tickets": client, "docs": client}
self.clients, self.route, self.tools = clients, {}, []
async def refresh(self):
self.route.clear(); self.tools.clear()
for server, client in self.clients.items():
cursor = None
while True:
page = await client.list_tools(cursor)
for t in page["tools"]:
fname = f"{server}__{t['name']}".replace(".", "_")[:64]
self.route[fname] = (server, t["name"])
self.tools.append({
"type": "function", "name": fname,
"description": t.get("description", "")[:1024],
"parameters": t["inputSchema"],
"strict": is_strict_compatible(t["inputSchema"]),
})
cursor = page.get("nextCursor")
if not cursor:
break
async def handle(self, call): # one function_call output item
if call["name"] not in self.route:
return self._out(call, {"error": f"unknown tool {call['name']}"})
server, tool = self.route[call["name"]]
try:
args = json.loads(call["arguments"])
res = await self.clients[server].call_tool(tool, args)
except Exception as e: # protocol or transport failure
return self._out(call, {"error": f"tool unavailable: {type(e).__name__}"})
body = res.get("structuredContent") or "\n".join(
c.get("text", "") for c in res.get("content", []) if c.get("type") == "text")
if res.get("isError"):
body = {"error": body}
return self._out(call, body)
def _out(self, call, body):
out = body if isinstance(body, str) else json.dumps(body)
return {"type": "function_call_output", "call_id": call["call_id"], "output": out}A few choices in that sketch are deliberate. The 64-character truncation and dot replacement are conservative defensive choices, not a quoted OpenAI limit: MCP allows dots and names up to 128 characters, so check your provider's current naming rules and make truncation collision-safe. Execution errors go back to the model as data so it can retry with better arguments, while transport failures are summarised rather than dumped, because stack traces are noise to a model and can leak internals. Non-text content such as images needs its own mapping or an explicit note that it was dropped. Finally, the adapter should re-run refresh when a server sends notifications/tools/list_changed, and the next model request should carry the new list.
Strict mode meets real MCP schemas
Strict mode guarantees that arguments match the schema, which removes a whole class of parse and validation failures. It also imposes rules: every object must set additionalProperties to false, every property must be listed in required, and optional fields are expressed by allowing null, for example a type of string or null. MCP servers are under no such constraint. Their inputSchema commonly has optional properties, omits additionalProperties, or uses JSON Schema keywords outside the subset strict mode supports.
There are three workable responses. Convert the schema: mark every property required, add null to the type of the ones that were optional, set additionalProperties to false, and then strip null-valued arguments before forwarding so the server sees the omission it expects. Fall back: send the tool with strict off when conversion is impossible, and validate the arguments against the original inputSchema yourself before calling the server. Or reject: refuse to expose tools whose schemas cannot be handled. Whichever you choose, keep the server's own validation in place, because the server must validate inputs regardless of what the client did.
The hosted mcp tool
The Responses API also offers pattern C: a tool of type mcp with a server_label and a server_url, and optionally allowed_tools to restrict which of the server's tools are exposed, require_approval to control whether calls need explicit approval, and authorization to pass an OAuth access token. The platform lists the server's tools (reported as an mcp_list_tools output item), calls them on the model's behalf (mcp_call items), and when approval is required emits an mcp_approval_request that your application answers with an mcp_approval_response.
This removes the adapter and a round trip per call, and it is the fastest way to give a model a remote tool. The costs are scope and control. OpenAI's guide says it works with remote servers over Streamable HTTP or HTTP with SSE and exposes only tools, not resources or prompts. The provider has to be able to reach the server, the tool results flow through the provider before you see them, and the access token you pass is presented by a third party. Treat the approval setting as a security control: leave approvals on for anything that writes or spends, and allow-list tools rather than exposing a whole server. The broader threat model is covered in MCP security.
Worked example: a support assistant
A team builds a support assistant on the Responses API. Their in-house ticketing system already has an MCP server used by the engineers' coding assistants, running behind the corporate network, and they want the public documentation search too. They keep the ticketing server on pattern B because it sits on the corporate network and they do not want ticket data passing through the provider. The adapter connects over Streamable HTTP, lists seven tools and finds that two have optional fields. It converts those schemas to strict form and strips nulls before forwarding.
A user asks why ticket 4812 has not moved. The model calls tickets__get_ticket with the id; the adapter routes it, the server returns structuredContent with status and assignee, and the adapter sends it back as the output. The model then calls tickets__add_comment, which the host's policy marks as a write, so the host asks the human support agent for confirmation before forwarding it, the same human-in-the-loop pattern the MCP specification recommends. For the public documentation search, a stateless remote server, they use the hosted mcp tool with allowed_tools set to the single search tool and approvals off, since it is read-only. One request now combines both patterns, and the same ticketing server keeps serving the coding assistants unchanged.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Model calls a tool that no longer exists | Server changed its list; host never refreshed | Handle list_changed; refresh on reconnect; return a clear unknown-tool output |
| Request rejected for invalid schema | MCP inputSchema not strict-compatible | Convert, fall back to non-strict with local validation, or exclude |
| Two tools with the same name | Different servers, no namespacing | Prefix with server label; keep a routing table |
| Model loops on a failing tool | Errors returned as empty strings | Return isError content as an explicit error object the model can read |
| Prompt injection through tool results | Untrusted text passed straight to the model | Treat results as data, confirm writes, limit tools per task |
| Context fills with tool definitions | Every tool from every server sent each turn | Expose only tools relevant to the task; allow-list per route |
Choosing
Use plain function calling when tools are a few functions inside one application and you have no reason to share them. Use MCP behind function calling when tools are shared across applications or teams, when you want to plug in third-party servers, when you need local tools over stdio, or when you might switch model providers. Use the hosted mcp tool for remote, internet-reachable servers where you accept the provider calling them and want minimal plumbing. Changing transports does not change this decision; the trade-offs between stdio and HTTP are covered in MCP transport architecture.
What to do next
- Draw your tool path and label each hop as function calling, MCP, or hosted mcp tool.
- If you bridge, write the adapter with namespaced names, a routing table, pagination and a list_changed refresh.
- Test every server's inputSchema for strict compatibility and decide per tool: convert, fall back or exclude.
- Map isError results to explicit error outputs, and summarise transport failures without stack traces.
- For the hosted tool, set allowed_tools, keep approvals on for writes, and scope the authorization token narrowly.
- Cap the number of tools sent per request and measure tool-selection accuracy as you add servers.