Chapter 15: OpenAI & ChatGPT
On every OpenAI surface, someone other than you decides how your server is described, approved and cached. The application developer writes its label and description. The developer’s code, or a ChatGPT user, approves each call. OpenAI’s infrastructure lists your tools once and reuses that list for the rest of the conversation. This chapter is about designing for that.
This chapter’s rule: write every name, description and annotation for a reader you will never meet: a developer configuring approvals, a model searching for tools, a user reading an approval dialog. The example is an inventory server at https://inventory.example.com/mcp, because a product catalog shows OpenAI’s surfaces better than notes do. The protocol is the same one the notes server speaks everywhere else in Part V.
Currency note. Product details in this chapter (API parameters, approval modes, connectors, plan availability) were checked against OpenAI’s documentation on 2026-09-27. They change often. The linked docs are the source of truth for those facts; the design decisions here do not depend on them.
Function Calling or MCP Comes First
The first decision an OpenAI builder makes is whether a tool should be a function or an MCP tool, and the two differ in who runs the code.
A function tool is a schema in the request. The model produces arguments; your application runs the function in its own process. Nothing connects to an outside server, and you control what runs, when, and with which credentials.
An MCP tool lives on a server. OpenAI’s infrastructure (or your SDK process) lists the server’s tools, calls them and returns the results. You give up some control and gain portability: the same server works in ChatGPT, Claude, the editors and your own client.
- Use a function when the logic belongs to this one application, or must run in your process for security reasons.
- Use MCP when the same capability should work in more than one host, or wraps a service that is naturally a remote endpoint.
- Use both when you have both. The Responses API accepts them in one request:
resp = client.responses.create(
model="gpt-5",
tools=[
{"type": "function", "name": "reserve_for_order", # runs in this app
"description": "Hold stock against a pending order in this app's cart.",
"parameters": {"type": "object", "properties": {"sku": {"type": "string"}},
"required": ["sku"], "additionalProperties": False}},
{"type": "mcp", "server_label": "inventory", # runs on the server
"server_url": "https://inventory.example.com/mcp",
"authorization": f"Bearer {inventory_token}", "require_approval": "never"},
],
input="Is SKU-4421 in stock in Chicago? If so, hold one for my order.",
)
The model checks stock through MCP and holds it through the function. Keep the names distinct across both kinds: a function and an MCP tool with the same name collide (see Failure Modes).
The Responses API Runs Your Server From OpenAI’s Side
With type: "mcp", OpenAI’s infrastructure does the MCP work for you: it lists the server’s tools, lets the model pick one, calls it and feeds back the result.
from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
model="gpt-5",
tools=[
{
"type": "mcp",
"server_label": "inventory",
"server_description": "Product inventory lookup and stock management.",
"server_url": "https://inventory.example.com/mcp",
"authorization": f"Bearer {inventory_token}",
"require_approval": "never",
},
],
input="Check stock levels for SKU-4421 in the Chicago warehouse.",
)
What that shape means for the server:
Remote only, unless tunnelled. The API reaches servers over Streamable HTTP or legacy HTTP/SSE via server_url. For a local or private server, OpenAI’s Secure MCP Tunnel runs a tunnel client beside the server and you pass a tunnel_id instead. Anything you intend to share should follow Chapter 7 to Streamable HTTP.
Someone else writes your description. server_label and server_description come from the application developer, not from your server. How findable your server is depends partly on their copy. If others will integrate it, publish the label and description you recommend.
The token arrives on every request. authorization is sent to your server as a bearer token and never stored by OpenAI, so the application supplies it on every call and owns refresh. The API does not run OAuth discovery for you. Your server’s job is unchanged: accept only tokens whose audience is your server (Chapter 19), and return a 401 or 403 the application can act on.
The tool list is cached in the conversation. The first call produces an mcp_list_tools output item. While that item stays in context, the API doesn’t list again. If your catalog changes mid-conversation, after a permission upgrade say, the model keeps the old list until the application removes the item. Keep the catalog stable within a conversation.
MCP activity comes back as typed output items: mcp_list_tools, mcp_call (arguments, output, errors), and mcp_approval_request / mcp_approval_response. Applications that log or replay conversations must handle all four.
The mcp_call item is where your error design becomes someone else’s log line. Its error field carries MCP protocol errors, tool execution errors and connection failures alike, so the text you put in a ToolError is what an integrator reads at 2 a.m.:
{
"type": "mcp_call",
"server_label": "inventory",
"name": "check_inventory",
"arguments": "{\"product_sku\": \"SKU-44211\", \"warehouse\": \"Chicago\"}",
"output": null,
"error": "Unknown product_sku 'SKU-44211'. SKUs are 'SKU-' plus 4 digits; did you mean SKU-4421?"
}
That message fixes the model’s next call and tells the integrator the server is fine. "Internal error" does neither, and the integrator’s first guess will be that your server is down.
Ship the Integration Snippet
Because the integrator writes your label, description and approval list, publish them. A short block in your README gets copied more often than any prose:
# Recommended Responses API config for inventory.example.com
INVENTORY_TOOL = {
"type": "mcp",
"server_label": "inventory",
"server_description": ("Product inventory lookup, stock levels, reorder "
"management and warehouse allocation for a retail "
"supply chain."),
"server_url": "https://inventory.example.com/mcp",
"require_approval": {"never": {"tool_names": [
"check_inventory", "list_warehouses", "get_reorder_status"]}},
}
# Token audience must be https://inventory.example.com/mcp; scope inventory:read
# for the three tools above, inventory:write for create_reorder.
The never list is your read-only tools, taken from the annotations and nothing else. When you add a tool, update the snippet in the same release, or integrators keep running last quarter’s list.
Approval Is Configured From Your Annotations
By default, every MCP call needs the developer’s approval, which is the right default and costs a round trip per call.
"require_approval": "always" # every call
"require_approval": "never" # no calls
"require_approval": { # skip approval for named tools only
"never": {"tool_names": ["search_products", "get_product_details"]}
}
Developers build that selective list from your annotations. A write tool marked readOnlyHint: true lands in someone’s never list and breaks their automation, not yours. The Part V opener covers the rule; the OpenAI-specific consequence is that the mistake surfaces in a stranger’s production.
The round trip is visible in code. With approval on, the first response carries an mcp_approval_request instead of calling your server, and nothing reaches the server until the application answers:
first = client.responses.create(model="gpt-5", tools=[inventory_tool], input=prompt)
for item in first.output:
if item.type == "mcp_approval_request":
ok = item.name in SAFE_TOOLS or ask_operator(item.name, item.arguments)
second = client.responses.create(
model="gpt-5",
tools=[inventory_tool],
previous_response_id=first.id,
input=[{"type": "mcp_approval_response",
"approval_request_id": item.id, "approve": ok}],
)
Each approval is a second model request. A workflow that makes four write calls pays four extra requests, which is why the developer wants every read tool in the never list, and why that list is only as safe as your annotations.
allowed_tools limits which of your tools the model sees at all:
{
"type": "mcp",
"server_label": "inventory",
"server_url": "https://inventory.example.com/mcp",
"require_approval": "never",
"allowed_tools": ["check_inventory", "list_warehouses"],
}
This only works if natural subsets of your tools work alone. check_inventory must not depend on an open_session call that the developer didn’t allow.
A Response Budget You Own
When OpenAI’s infrastructure calls your server, you don’t control the timeout or the retries, and OpenAI doesn’t publish the timeout. Set your own budget: answer within 10 seconds, or return a handle and let a second tool fetch the result. That is the start_export / get_export pattern from Chapter 7, applied to a stock recount:
start_recount(warehouse="Chicago") -> {"recount_id": "rc_7f3a", "status": "running"}
get_recount(recount_id="rc_7f3a") -> {"status": "done", "discrepancies": 3}
A tool that sometimes takes 40 seconds will fail in some hosts and succeed in others, and nobody will be able to reproduce it.
Connectors Are the Baseline You Compete With
Connectors are OpenAI-maintained MCP wrappers for eight services: Dropbox, Gmail, Google Calendar, Google Drive, Microsoft Teams, Outlook Calendar, Outlook Email and SharePoint. They use the same type: "mcp" shape with a connector_id instead of a server_url. OpenAI now calls them legacy: connector_id is deprecated for models released after September 1, 2026, and older models keep it.
If your server wraps one of those services, the connector is what you compete with. You need a reason to exist beyond basic CRUD — custom business logic, multi-tenant isolation, or cross-service aggregation that the connector does not provide.
Connectors also set an expectation about output: typed JSON in, typed JSON out. Meet it with an outputSchema and structuredContent (Chapter 6). The argument isn’t about the developer’s parser. A result with a schema can be validated by the host, read by the model as fields, and passed straight into a function tool in the same request. A Markdown string has to be re-read by the model every time, and a field it misreads becomes a wrong argument to the next call.
Tool Search Turns Your Description Into a Search Surface
When the session crosses the opener’s ~40-tool line, set defer_loading: true on the server:
{
"type": "mcp",
"server_label": "inventory",
"server_description": "Product inventory lookup, stock levels, reorder management and warehouse allocation for a retail supply chain.",
"server_url": "https://inventory.example.com/mcp",
"defer_loading": true,
"require_approval": "never"
}
The model first sees only the label and description. If they look relevant, it runs a tool search (tool_search_call and tool_search_output items) and loads the subset it needs. Search runs hosted, inside OpenAI, or client-executed, where your application returns the tools it chooses based on tenant or project state.
The consequence: under deferred loading, server_description is the only thing the model can find you by. “Various useful tools” is never loaded. The description above is. It follows that several focused servers with sharp descriptions beat one server with forty tools, because each server becomes its own search result.
The Agents SDK: Hosted or Local
The Agents SDK (Python and TypeScript) sits on the Responses API and offers two ways to reach a server.
HostedMCPTool wraps the Responses API integration: the whole round trip happens inside OpenAI, and your process never talks to the server. Its on_approval_request callback is where approval policy lives:
from agents import Agent, HostedMCPTool, MCPToolApprovalFunctionResult, MCPToolApprovalRequest, Runner
SAFE_TOOLS = {"check_inventory", "list_warehouses"}
def approve_tool(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult:
if request.data.name in SAFE_TOOLS:
return {"approve": True}
return {"approve": False, "reason": "Requires human review"}
agent = Agent(
name="Assistant",
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "inventory",
"server_url": "https://inventory.example.com/mcp",
"require_approval": "always",
},
on_approval_request=approve_tool,
)
],
)
result = await Runner.run(agent, "Check stock for SKU-4421")
The allowlist is by name, not by annotation, on purpose. It is the application’s decision about this server, and it doesn’t change if the server’s annotations do.
Local transports (MCPServerStdio, MCPServerSse, MCPServerStreamableHttp) connect from your own process:
from agents.mcp import MCPServerStreamableHttp
async with MCPServerStreamableHttp(
name="inventory",
params={
"url": "https://inventory.example.com/mcp",
"headers": {"Authorization": f"Bearer {inventory_token}"},
"timeout": 10,
},
cache_tools_list=True,
max_retry_attempts=3,
) as server:
agent = Agent(name="Assistant", mcp_servers=[server])
result = await Runner.run(agent, "Check stock for SKU-4421")
They accept the same require_approval shapes, plus tool_filter and cache_tools_list. The choice:
| Factor | HostedMCPTool | Local transports |
|---|---|---|
| Who calls the server | OpenAI | Your process |
| Who sets timeouts and retries | OpenAI | You |
| Errors | mcp_call.error field | Full exception handling |
| Resources and prompts | Not supported | Supported |
| stdio | No | Yes |
Hosted MCP supports tools only. If your server’s value depends on resources or prompts, the local transports are the only OpenAI path that sees them, which is the opener’s capability floor again.
The SDK’s convert_schemas_to_strict option tries to turn your input schemas into OpenAI’s strict format and falls back to the original when it can’t. Make it succeed: flat schemas, explicit required, additionalProperties: false, no oneOf.
ChatGPT Users Read Your Argument Names
In ChatGPT, the person approving a call is an end user, not a developer. ChatGPT shows the tool call in the conversation and asks whether to proceed. Your tool names and argument names are UI copy.
A tool called exec_inv_query with arguments {"q": "sku:4421", "wh_id": "CHI01"} is usable by a developer who has read the spec. It is not usable by a ChatGPT user who sees the approval dialog. Here is the definition that produced that dialog, and its fix:
# Before: the dialog shows exec_inv_query(q="sku:4421", wh_id="CHI01")
@mcp.tool()
def exec_inv_query(q: str, wh_id: str) -> dict:
"""Run an inventory query."""
# After: the dialog shows check_inventory(product_sku="SKU-4421", warehouse="Chicago")
@mcp.tool(annotations=ToolAnnotations(title="Check inventory", readOnlyHint=True))
def check_inventory(
product_sku: Annotated[str, Field(description="Product SKU, e.g. SKU-4421")],
warehouse: Annotated[str, Field(description="Warehouse city, e.g. Chicago")],
) -> StockLevel:
"""Return on-hand and reserved stock for one product in one warehouse."""
The second version costs one extra lookup inside the tool, from “Chicago” to CHI01. That is the server doing work so the user can make an informed decision. StockLevel is the output schema, and it is small on purpose:
class StockLevel(BaseModel):
product_sku: str
warehouse: str
on_hand: int
reserved: int
available: int # on_hand - reserved, computed by the server
as_of: datetime
available is there so the model never does the subtraction. as_of is there because a stock count without a time is a guess.
ChatGPT Signs In Only When the Server Asks
ChatGPT shows its account-linking UI for a tool only when the server provides two things. The first is metadata: a protected-resource document, plus a securitySchemes entry on each tool (noauth, oauth2 with its scopes, or both for “works anonymously, better signed in”). The second is a runtime signal: an unauthenticated call returns an error that carries the WWW-Authenticate challenge in _meta["mcp/www_authenticate"]. With only one half, ChatGPT never prompts, and the tool simply fails for users who haven’t linked.
The server I run for Atelier customers does both. Discovery is open, so ChatGPT can list tools before sign-in. Every tool advertises an oauth2 scheme with the scopes it needs. An unauthenticated or invalid-token tools/call gets a Bearer challenge naming the resource metadata and scope. Before submission, a read-only smoke test checks exactly those surfaces against production. Test both halves from an account that has never linked the app; the developer’s own account, already signed in, can’t show you the failure.
Apps SDK: Build the Server First
The ChatGPT Apps SDK adds widgets (interactive UI rendered in the chat), actions and, depending on OpenAI’s current program, commerce. None of it reaches the Responses API or the Agents SDK. Build in this order: a server with clean tools and honest annotations; verified in the Responses API and in ChatGPT as a plain integration; and only then widgets where they help. The Apps SDK changes between review cycles. The tools underneath are the stable contract.
Skills. OpenAI’s Skills are instruction bundles with a SKILL.md, mounted into shell-tool environments; they are not MCP servers. The split is the one Chapter 14 draws: servers carry access, skills carry method.
Realtime. MCP tools also work in Realtime API voice agents, where a 5-second tool call is five seconds of silence. For voice, keep the default path under a second and put the slow detail behind a second tool the agent calls only if asked.
Failure Modes
Authorization token not persisted. The Responses API doesn’t store authorization. A follow-up turn without it reaches your server unauthenticated and fails. Pass the token on every request.
Connector scope mismatch. The token carries calendar.events.readonly, the model calls create_event, and the call fails; the model may retry or claim success. Publish the minimum scopes for each tool.
Schema strictness failures. With convert_schemas_to_strict, a schema using oneOf, recursion or additionalProperties: true fails conversion and falls back silently, and argument quality drops with no error anywhere.
Server unreachable during hosted execution. OpenAI calls your server; your server is down; mcp_call.error records a connection error and the model may carry on without the result. Health checks and fast connection setup are the only defences you control.
Function and MCP name collisions. A function tool get_customer and an MCP tool get_customer in one request, and the model calls either. Use distinct names across both kinds; server authors can prefix (inventory_get_customer) to stay out of the way.
Implementation Checklist
- Publish the
server_labelandserver_descriptionyou recommend, written for tool search - Every tool has honest
readOnlyHintanddestructiveHintannotations developers can build aneverlist from - Tool and argument names read correctly in a ChatGPT approval dialog
- Natural subsets of tools work alone under
allowed_tools - Every tool answers within 10 seconds or returns a handle
- Results carry
structuredContentwith anoutputSchema - The catalog is stable within a conversation
- Tested through the Responses API with
always, selective andneverapproval, and withdefer_loading: true
What Comes Next
On OpenAI’s surfaces, someone else describes, approves and caches your server, so the server’s own words have to carry it. Developer tools remove that intermediary and hand the model a shell, a repository and the user’s credentials. Chapter 16 turns to what your server must refuse when the host won’t.