Announcing CIMD support for MCP Client registration
Learn more

OpenAI Agents SDK MCP support: servers, auth and per-user OAuth

Diagram of an OpenAI Agents SDK run that mints a per-user session token and connects to a Scalekit Virtual MCP server through MCPServerStreamableHttp
Nityashree Yadunath
Product Marketing Manager

The OpenAI Agents SDK for Python (openai-agents) supports MCP in two ways. Local server classes in agents.mcp (MCPServerStreamableHttp, MCPServerSse and MCPServerStdio) connect from your process, list the tools and run each call. HostedMCPTool hands a public server to OpenAI's Responses API, which calls it for the model. Either way, every tool call acts as whoever owns the credential you pass.

For one developer, that credential is a token in an environment variable. A product that serves many users needs a credential per user per run, which comes from your backend or a managed MCP integration layer, plus limits on which tools the model sees and which calls wait for a person.

TL;DR

  • Use MCPServerStreamableHttp for remote servers your code connects to, with the credential in params["headers"]. MCPServerSse is for older servers and MCPServerStdio starts a local process. HostedMCPTool lets the Responses API call a public server and takes an OAuth access token in authorization.
  • tool_filter, cache_tools_list and require_approval decide which tools the model sees, how often the tool list is fetched, and which calls pause for approval.
  • For user-scoped OAuth, open a new server object for each run with a token minted for that user. With Scalekit, one Virtual MCP server URL serves every user, and a short-lived session token decides whose connected accounts the tools use.

The MCP options in the OpenAI Agents SDK

The Agents SDK MCP docs list four ways to attach a server. The first three are objects you pass to Agent(mcp_servers=[...]). The fourth is a tool you add to Agent(tools=[...]).

Option
Who calls the server
Credential
Use it when
MCPServer​StreamableHttp
Your Python process
Headers, or an auth object in params
The server speaks Streamable HTTP and you want filters, approvals and guardrails in your code
MCPServerSse
Your Python process
Headers, or an auth object in params
The server only offers HTTP with SSE
MCPServerStdio
Your process, through a subprocess it starts
Environment variables for the subprocess
The server is a local command, such as an npx package
HostedMCPTool
OpenAI's Responses API
authorization or headers in tool_config
The server is publicly reachable and the model supports hosted MCP

The three local classes share the options covered below (tool filters, tool list caching and approval policies), plus retries and tool guardrails. Client-side tool guardrails do not apply to HostedMCPTool, because the Responses API runs those calls. For several local servers, MCPServerManager connects them up front and exposes the ones that connected through active_servers.

The SDK works with both major versions of the mcp Python package, 1.x and 2.x, and detects which one is installed. The version matters when you pass your own HTTP client or auth object.

Connect a Streamable HTTP server

Install the SDK with pip install openai-agents. A Streamable HTTP server takes a URL and headers, and the context manager connects on entry and cleans up on exit:

import asyncio import os from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp async def main() -> None: token = os.environ["MCP_SERVER_TOKEN"] async with MCPServerStreamableHttp( name="docs", params={ "url": "https://mcp.example.com/mcp", "headers": {"Authorization": f"Bearer {token}"}, "timeout": 30, }, client_session_timeout_seconds=30, ) as server: agent = Agent( name="Assistant", instructions="Use the MCP tools to answer.", mcp_servers=[server], ) result = await Runner.run(agent, "What changed in the last release?") print(result.final_output) asyncio.run(main())

The params dictionary holds the connection settings:

  • url and headers.
  • timeout for each HTTP request, which defaults to 5 seconds, and sse_read_timeout for the event stream, which defaults to 5 minutes.
  • terminate_on_close, auth and httpx_client_factory.

The constructor adds the client-side behaviour:

  • client_session_timeout_seconds is the MCP session read timeout. It also defaults to 5 seconds, and None or 0 turns it off. Raise it, with timeout, for tools that call slow APIs.
  • max_retry_attempts and retry_backoff_seconds_base retry failed list_tools() and call_tool() requests.
  • failure_error_function sets the error text the model sees when a call fails, or raises when set to None.
  • tool_meta_resolver adds per-call MCP _meta values, such as a tenant ID read from the run context.

Headers and auth options

An Authorization header is the option the SDK docs recommend. It works the same with both mcp versions. For a custom scheme, params["auth"] takes an auth object from the HTTP stack the installed mcp package uses: httpx.Auth with mcp 1.x, httpx2.Auth with mcp 2.x. The same rule applies to the client returned by httpx_client_factory. With mcp 2.x installed, the SDK rejects any other auth type with a UserError before it connects.

Two more rules apply. Keep tokens in headers, never in the URL, as the SDK's security note advises. And treat the headers as fixed for the life of the object: they are read when it connects, so a new credential means a new server object or an auth object that sets the header on each request.

SSE and stdio servers

MCPServerSse takes the same params keys apart from terminate_on_close, and the same constructor options. The SDK docs note that the MCP project has deprecated the SSE transport, so keep it for servers that offer nothing else.

MCPServerStdio starts a subprocess from command and args, with optional env, cwd and encoding, and closes it when the context manager exits. A stdio server usually reads its credential from the environment you give it, so each process holds one credential. That suits a developer tool, while a service acting for many users fits a remote server better. Remote vs stdio MCP servers compares the two transports.

The hosted MCP tool

HostedMCPTool sends a server's details to the Responses API with each model request. OpenAI's infrastructure lists the tools and calls them, with no round trip to your Python process. Its tool_config mirrors the Responses API mcp tool: type is "mcp", server_label names the server, and server_url points to it. Optional keys include allowed_tools, require_approval, server_description and defer_loading. Because OpenAI makes the calls, the server must be reachable from the internet. For a private server, the Responses API accepts tunnel_id for OpenAI's Secure MCP Tunnel in place of server_url.

The auth fields, as OpenAI's MCP guide and API reference describe them:

  • authorization holds an OAuth access token your application obtained. The Responses API does not store the value and does not show it in the Response object, so it has to be sent with every request.
  • headers holds optional HTTP headers for the MCP server, for authentication or other purposes.
  • connector_id selects one of OpenAI's built-in connectors, such as Gmail or Google Calendar, with the token in authorization. OpenAI deprecates it for models released after September 1, 2026, in favour of server_url or tunnel_id.

OpenAI asks for approval before data is shared with a remote server unless you set require_approval to "never" or list the tools that skip it. In the SDK, an on_approval_request callback can decide in code by returning {"approve": True} or a rejection with a reason. Without a callback, the request shows up as an interruption that you resolve like any other approval. The API keeps the imported tool list as an mcp_list_tools item, and it does not fetch the list again while that item stays in the conversation context.

The hosted tool is the better fit when the server is public and you want OpenAI to run the tool loop. The local classes fit when you need tool guardrails or want your own process to see and log each call.

Filter the tools the model sees

Every tool on a server goes into the model's context. create_static_tool_filter keeps an allowlist, a blocklist or both. With both, the SDK applies the allowlist first and then removes blocked names. This read-only variant of a Slack server drops the one tool that posts:

from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter from scalekit_session import MCP_SERVER_URL def read_only_server(token: str) -> MCPServerStreamableHttp: return MCPServerStreamableHttp( name="slack-read", params={ "url": MCP_SERVER_URL, "headers": {"Authorization": f"Bearer {token}"}, }, tool_filter=create_static_tool_filter( blocked_tool_names=["slack_send_message"], ), )

For logic that depends on the run, pass a function as tool_filter. It receives a ToolFilterContext with run_context, agent and server_name, plus the tool, and returns whether to include it. A function filter needs that context, so it only works inside a run: calling server.list_tools() directly on a server with a function filter raises a UserError. On the hosted tool, allowed_tools takes a list of names or a filter object whose read_only flag matches tools annotated with readOnlyHint.

If two servers share tool names, set include_server_in_tool_names to True in Agent.mcp_config to prefix each name with its server.

Cache the tool list

The runner asks each local server for its tools on every turn of a run. With caching off, a run that takes three model turns sends three tools/list requests. With cache_tools_list=True it sends one. Cache only when the tool definitions rarely change, and call invalidate_tools_cache() to force a fresh list.

The cache lives on the server object. When you build one object per run, as in the per-user pattern below, each run fetches the list once, and a change to the server's tools shows up on the next run.

Require approval for tools that write

All three local classes accept require_approval:

  • "always" or "never" for every tool, with True and False as aliases.
  • A map of tool names to policies, such as {"send_message": "always"}.
  • A grouped object: {"always": {"tool_names": [...]}}.
  • A function of (run_context, agent, tool) that returns True when the call needs approval.

The function sees the tool definition and its annotations, but not the call's arguments. The SDK docs describe server annotations as advisory hints, so skip approval based on them only for servers you trust.

When a call needs approval, the run stops and result.interruptions lists the pending calls with their tool name and arguments. Convert the result with result.to_state(), call state.approve(item) or state.reject(item), then pass the state back to Runner.run. A rejected call is never sent to the server; the model gets a message that the tool execution was not approved. always_approve=True keeps a decision for later calls to the same tool while the same run context is reused.

User-scoped OAuth with a per-run credential

The headers on a server object belong to one user, so an object cannot be shared between two users' runs. In a multi-user product, every run opens its own object with a credential that belongs to the user the run acts for. That credential should be short-lived, minted by your backend, and different from the user's raw OAuth tokens. Per-user OAuth for MCP compares the designs for storing and resolving those tokens.

A managed MCP gateway holds each user's app tokens and issues the short-lived credential. In Scalekit AgentKit, the pieces are:

  • A Virtual MCP server: one URL per agent role that exposes only the tools you choose, from one or more of Scalekit's 500+ connectors. All users share the URL.
  • A session token minted per run and bound to one user and one server. It lasts one hour by default, can be set from 60 seconds to 24 hours, and cannot be refreshed: you mint a new one. A token used on another server URL gets a 401.
  • Connected accounts that hold each user's OAuth tokens. The agent never receives them. By default, tools/list also includes a connect_account tool. When the user still needs to connect an app, the agent calls it to get an authorization link for the user to open.

Create the Virtual MCP server once

Install scalekit-sdk-python next to the Agents SDK, then create one server for the agent role. This example uses a Slack connection named slack in the Scalekit dashboard and prints the server ID and the shared URL. Store them as MCP_CONFIG_ID and MCP_SERVER_URL.

import os from scalekit import ScalekitClient from scalekit.actions.models.mcp_config import ( McpConfigConnectionToolMapping, ) actions = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ).actions response = actions.mcp.create_config( name="openai-agents-slack", description="Slack tools for the OpenAI Agents SDK assistant", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="slack", tools=[ "slack_list_channels", "slack_fetch_conversation_history", "slack_send_message", ], ), ], ) print(response.config.id) print(response.config.mcp_server_url)

Check connections and mint the token

Save these two helpers as scalekit_session.py. The first returns a sign-in link for each app the user has not connected. Authorization links expire, so create them just before you show them. The second mints a session token for one user. The user ID is your app's stable ID for the person; Scalekit's docs recommend an ID that is hard to guess over an email address.

import os from datetime import timedelta from scalekit import ScalekitClient actions = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ).actions MCP_CONFIG_ID = os.environ["MCP_CONFIG_ID"] MCP_SERVER_URL = os.environ["MCP_SERVER_URL"] def links_to_authorize(user_id: str) -> list[str]: """Return a sign-in link for each app the user has not connected.""" response = actions.mcp.list_mcp_connected_accounts( config_id=MCP_CONFIG_ID, identifier=user_id, include_auth_link=True, ) return [ account.authentication_link for account in response.connected_accounts if account.connected_account_status != "ACTIVE" ] def mint_session_token(user_id: str, minutes: int = 30) -> str: """Mint a session token for one user on the Virtual MCP server.""" session = actions.mcp.create_session_token( mcp_config_id=MCP_CONFIG_ID, identifier=user_id, expiry=timedelta(minutes=minutes), ) return session.token

Test the connection without a model

Save this as smoke_test.py and run python smoke_test.py <user-id>. It opens the server with the user's token, prints the tool names, and calls one tool through the server object, so you can check auth before spending model tokens.

import asyncio import sys from agents.mcp import MCPServerStreamableHttp from scalekit_session import MCP_SERVER_URL, mint_session_token async def main(user_id: str) -> None: token = await asyncio.to_thread(mint_session_token, user_id) async with MCPServerStreamableHttp( name="slack", params={ "url": MCP_SERVER_URL, "headers": {"Authorization": f"Bearer {token}"}, "timeout": 30, }, client_session_timeout_seconds=30, ) as server: tools = await server.list_tools() print([tool.name for tool in tools]) result = await server.call_tool("slack_list_channels", {"limit": 5}) print(result.content[0].text[:500]) asyncio.run(main(sys.argv[1]))

Read the result body, not only the error flag. When the app answers with an error status, the tool result is marked as an error. When the app reports an error inside a successful response, such as an expired Slack authorization, the result is not flagged and the app's error sits under data. A wrong or expired session token gets a 401 from the server. Opening the server object fails, or, if the token expires while the object is open, the next request fails. Mint a new token and open a new server object.

Run the agent for one user

Save this as agent_run.py. It checks the user's connections, mints a token, opens a server object for this run only, and pauses for a person before any slack_send_message call. The model call needs OPENAI_API_KEY in the environment, as well as the Scalekit variables and the two MCP values.

import asyncio import sys from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp from scalekit_session import ( MCP_SERVER_URL, links_to_authorize, mint_session_token, ) WRITE_TOOLS = ["slack_send_message"] def ask_reviewer(item) -> bool: answer = input(f"Allow {item.tool_name} {item.arguments}? [y/N] ") return answer.strip().lower() == "y" async def run_for_user(user_id: str, prompt: str) -> str: links = await asyncio.to_thread(links_to_authorize, user_id) if links: return "Connect these apps first: " + " ".join(links) token = await asyncio.to_thread(mint_session_token, user_id) async with MCPServerStreamableHttp( name="slack", params={ "url": MCP_SERVER_URL, "headers": {"Authorization": f"Bearer {token}"}, "timeout": 30, }, client_session_timeout_seconds=30, cache_tools_list=True, require_approval={"always": {"tool_names": WRITE_TOOLS}}, ) as server: agent = Agent( name="Slack assistant", instructions="Answer questions with the user's Slack data.", mcp_servers=[server], ) result = await Runner.run(agent, prompt) while result.interruptions: state = result.to_state() for item in result.interruptions: if ask_reviewer(item): state.approve(item) else: state.reject(item) result = await Runner.run(agent, state) return result.final_output if __name__ == "__main__": print(asyncio.run(run_for_user(sys.argv[1], sys.argv[2])))

Each concurrent run gets its own token and its own server object, and minting a new token does not revoke the others. scalekit_session.py mints 30-minute tokens; set the expiry a little above your longest run. For a chat session that outlives the token, mint a new one when a call gets a 401 and reconnect.

This version prompts on the terminal. In a web app, store the paused run with state.to_string(), ask the user in your UI, and load it with await RunState.from_string(agent, saved) on a new server object with a freshly minted token, since the original token may expire while the run waits. Keep the saved state on your server: it holds the pending tool arguments, and the SDK does not authenticate a snapshot when it loads one.

Session tokens and the hosted tool

HostedMCPTool takes its credential in tool_config: authorization for an OAuth access token, or headers for HTTP headers sent to the MCP server. With the hosted tool, that credential leaves your backend. It goes to OpenAI with every Responses API request, and OpenAI's infrastructure connects to the server and calls the tools. OpenAI states that the Responses API does not store the authorization value. For per-user runs, MCPServerStreamableHttp keeps the session token, the approval step and any guardrails inside your own process.

Production notes

  • Keep the Scalekit client credentials on your backend. Anyone holding them can mint a token for any identifier, so the code that mints tokens should only do so for the signed-in user.
  • For runs that only read, add access_level="READ_ONLY" to create_session_token. Tools not marked read-only drop out of the tool list and are refused if called.
  • Show the link from connect_account to the user. The agent should not open it itself.

FAQs

Does the OpenAI Agents SDK support MCP?

Yes. The Python SDK ships MCPServerStreamableHttp, MCPServerSse and MCPServerStdio for servers your process connects to, and HostedMCPTool for public servers that OpenAI's Responses API calls for the model. It works with both the 1.x and 2.x versions of the mcp Python package.

What is the difference between HostedMCPTool and MCPServerStreamableHttp?

MCPServerStreamableHttp connects from your process, so you control filters, approvals, guardrails and logging, and the server can be private. HostedMCPTool hands the server to the Responses API, which lists and calls the tools without a round trip to your code, so the server must be publicly reachable or behind OpenAI's Secure MCP Tunnel.

How do I pass an OAuth token to an MCP server in the OpenAI Agents SDK?

For the local classes, put it in params headers as an Authorization bearer header, or pass an auth object that matches your installed mcp version. For HostedMCPTool, OpenAI's docs describe the authorization field for an OAuth access token. The Responses API does not store that value, so send it with every request.

How do I limit which MCP tools an agent can call?

Pass tool_filter with create_static_tool_filter for allowlists and blocklists, or a function that decides per run. On HostedMCPTool, use allowed_tools. A server that exposes only the tools an agent needs, such as a Scalekit Virtual MCP server, limits the list before the SDK sees it.

How do I require approval before an MCP tool runs?

Set require_approval on the server object to always, a per-tool map or a function. The run pauses with interruptions that you approve or reject on the RunState before resuming. HostedMCPTool takes require_approval in tool_config and an optional on_approval_request callback.

How do I give each user their own MCP credentials?

Open a new server object for each run, with headers that carry a credential minted for that user. With Scalekit, all users share one Virtual MCP server URL, and each run sends a session token bound to one user, valid for one hour by default.

Details checked October 2026.

No items found.
On this page
Share this article

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.