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.

Advertisement

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.

Three ways to wire tools to a modelA. Function calling onlyModel APIfunction_callYour appruns the codeIn-process toolsyour functionsB. Your app as an MCP host, bridging to function callingModel APIfunction_callHost + adapterMCP client per serverMCP serverstdioMCP serverHTTPtools/callC. Hosted MCP tool: the model platform calls the serverYour appconfigures toolModel platformmcp tool, approvalsRemote MCP serverpublic endpointtools/callFunction calling is the model-to-application contract; MCP is the application-to-tool protocol.Pattern B uses both. Pattern C moves the MCP client to the model provider.
Pattern A keeps tools in-process. Pattern B, the common production shape, uses function calling to talk to the model and MCP to talk to tools. Pattern C hands the MCP client role to the model platform.

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.

Advertisement

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

DimensionOpenAI function callingMCP tools
LayerModel to applicationApplication (host) to tool server
Who defines toolsYour app, per requestThe server, discovered at runtime
Who executesYour appThe server process
DiscoveryNone; you send the listtools/list with pagination; list_changed notifications
Input schemaJSON Schema in parameters; strict subset when strictJSON Schema in inputSchema, 2020-12 by default
OutputA string or content you choosecontent items, optional structuredContent with outputSchema
ErrorsWhatever you put in the outputJSON-RPC protocol errors vs isError results
TransportHTTPS to the model APIstdio or Streamable HTTP, JSON-RPC 2.0
Reuse across modelsFormat is provider-specificSame 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

SymptomCauseFix
Model calls a tool that no longer existsServer changed its list; host never refreshedHandle list_changed; refresh on reconnect; return a clear unknown-tool output
Request rejected for invalid schemaMCP inputSchema not strict-compatibleConvert, fall back to non-strict with local validation, or exclude
Two tools with the same nameDifferent servers, no namespacingPrefix with server label; keep a routing table
Model loops on a failing toolErrors returned as empty stringsReturn isError content as an explicit error object the model can read
Prompt injection through tool resultsUntrusted text passed straight to the modelTreat results as data, confirm writes, limit tools per task
Context fills with tool definitionsEvery tool from every server sent each turnExpose 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

  1. Draw your tool path and label each hop as function calling, MCP, or hosted mcp tool.
  2. If you bridge, write the adapter with namespaced names, a routing table, pagination and a list_changed refresh.
  3. Test every server's inputSchema for strict compatibility and decide per tool: convert, fall back or exclude.
  4. Map isError results to explicit error outputs, and summarise transport failures without stack traces.
  5. For the hosted tool, set allowed_tools, keep approvals on for writes, and scope the authorization token narrowly.
  6. Cap the number of tools sent per request and measure tool-selection accuracy as you add servers.
Key takeaway: Function calling and MCP solve different problems and compose. Function calling is the contract by which a model asks your application to run a tool; MCP is the protocol by which your application finds and runs tools hosted elsewhere. Bridge them with an adapter that namespaces names, handles strict schemas and maps errors faithfully, or let the model platform be the MCP client with the hosted mcp tool when the server is remote and you accept that trust boundary.