Skip to content

Hosted MCP support #731

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added examples/hosted_mcp/__init__.py
Empty file.
61 changes: 61 additions & 0 deletions examples/hosted_mcp/approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import argparse
import asyncio

from agents import (
Agent,
HostedMCPTool,
MCPToolApprovalFunctionResult,
MCPToolApprovalRequest,
Runner,
)

"""This example demonstrates how to use the hosted MCP support in the OpenAI Responses API, with
approval callbacks."""


def approval_callback(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult:
answer = input(f"Approve running the tool `{request.data.name}`? (y/n) ")
result: MCPToolApprovalFunctionResult = {"approve": answer == "y"}
if not result["approve"]:
result["reason"] = "User denied"
return result


async def main(verbose: bool, stream: bool):
agent = Agent(
name="Assistant",
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "gitmcp",
"server_url": "https://gitmcp.io/openai/codex",
"require_approval": "always",
},
on_approval_request=approval_callback,
)
],
)

if stream:
result = Runner.run_streamed(agent, "Which language is this repo written in?")
async for event in result.stream_events():
if event.type == "run_item_stream_event":
print(f"Got event of type {event.item.__class__.__name__}")
print(f"Done streaming; final result: {result.final_output}")
else:
res = await Runner.run(agent, "Which language is this repo written in?")
print(res.final_output)

if verbose:
for item in result.new_items:
print(item)


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true", default=False)
parser.add_argument("--stream", action="store_true", default=False)
args = parser.parse_args()

asyncio.run(main(args.verbose, args.stream))
47 changes: 47 additions & 0 deletions examples/hosted_mcp/simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import argparse
import asyncio

from agents import Agent, HostedMCPTool, Runner

"""This example demonstrates how to use the hosted MCP support in the OpenAI Responses API, with
approvals not required for any tools. You should only use this for trusted MCP servers."""


async def main(verbose: bool, stream: bool):
agent = Agent(
name="Assistant",
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "gitmcp",
"server_url": "https://gitmcp.io/openai/codex",
"require_approval": "never",
}
)
],
)

if stream:
result = Runner.run_streamed(agent, "Which language is this repo written in?")
async for event in result.stream_events():
if event.type == "run_item_stream_event":
print(f"Got event of type {event.item.__class__.__name__}")
print(f"Done streaming; final result: {result.final_output}")
else:
res = await Runner.run(agent, "Which language is this repo written in?")
print(res.final_output)
# The repository is primarily written in multiple languages, including Rust and TypeScript...

if verbose:
for item in result.new_items:
print(item)


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true", default=False)
parser.add_argument("--stream", action="store_true", default=False)
args = parser.parse_args()

asyncio.run(main(args.verbose, args.stream))
8 changes: 8 additions & 0 deletions src/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
FileSearchTool,
FunctionTool,
FunctionToolResult,
HostedMCPTool,
MCPToolApprovalFunction,
MCPToolApprovalFunctionResult,
MCPToolApprovalRequest,
Tool,
WebSearchTool,
default_tool_error_function,
Expand Down Expand Up @@ -208,6 +212,10 @@ def enable_verbose_stdout_logging():
"FileSearchTool",
"Tool",
"WebSearchTool",
"HostedMCPTool",
"MCPToolApprovalFunction",
"MCPToolApprovalRequest",
"MCPToolApprovalFunctionResult",
"function_tool",
"Usage",
"add_trace_processor",
Expand Down
114 changes: 108 additions & 6 deletions src/agents/_run_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
ActionType,
ActionWait,
)
from openai.types.responses.response_input_param import ComputerCallOutput
from openai.types.responses.response_input_param import ComputerCallOutput, McpApprovalResponse
from openai.types.responses.response_output_item import McpApprovalRequest, McpCall, McpListTools
from openai.types.responses.response_reasoning_item import ResponseReasoningItem

from .agent import Agent, ToolsToFinalOutputResult
Expand All @@ -38,6 +39,9 @@
HandoffCallItem,
HandoffOutputItem,
ItemHelpers,
MCPApprovalRequestItem,
MCPApprovalResponseItem,
MCPListToolsItem,
MessageOutputItem,
ModelResponse,
ReasoningItem,
Expand All @@ -52,7 +56,14 @@
from .models.interface import ModelTracing
from .run_context import RunContextWrapper, TContext
from .stream_events import RunItemStreamEvent, StreamEvent
from .tool import ComputerTool, FunctionTool, FunctionToolResult, Tool
from .tool import (
ComputerTool,
FunctionTool,
FunctionToolResult,
HostedMCPTool,
MCPToolApprovalRequest,
Tool,
)
from .tracing import (
SpanError,
Trace,
Expand Down Expand Up @@ -112,22 +123,30 @@ class ToolRunComputerAction:
computer_tool: ComputerTool


@dataclass
class ToolRunMCPApprovalRequest:
request_item: McpApprovalRequest
mcp_tool: HostedMCPTool


@dataclass
class ProcessedResponse:
new_items: list[RunItem]
handoffs: list[ToolRunHandoff]
functions: list[ToolRunFunction]
computer_actions: list[ToolRunComputerAction]
tools_used: list[str] # Names of all tools used, including hosted tools
mcp_approval_requests: list[ToolRunMCPApprovalRequest] # Only requests with callbacks

def has_tools_to_run(self) -> bool:
def has_tools_or_approvals_to_run(self) -> bool:
# Handoffs, functions and computer actions need local processing
# Hosted tools have already run, so there's nothing to do.
return any(
[
self.handoffs,
self.functions,
self.computer_actions,
self.mcp_approval_requests,
]
)

Expand Down Expand Up @@ -226,7 +245,16 @@ async def execute_tools_and_side_effects(
new_step_items.extend([result.run_item for result in function_results])
new_step_items.extend(computer_results)

# Second, check if there are any handoffs
# Next, run the MCP approval requests
if processed_response.mcp_approval_requests:
approval_results = await cls.execute_mcp_approval_requests(
agent=agent,
approval_requests=processed_response.mcp_approval_requests,
context_wrapper=context_wrapper,
)
new_step_items.extend(approval_results)

# Next, check if there are any handoffs
if run_handoffs := processed_response.handoffs:
return await cls.execute_handoffs(
agent=agent,
Expand All @@ -240,7 +268,7 @@ async def execute_tools_and_side_effects(
run_config=run_config,
)

# Third, we'll check if the tool use should result in a final output
# Next, we'll check if the tool use should result in a final output
check_tool_use = await cls._check_for_final_output_from_tools(
agent=agent,
tool_results=function_results,
Expand Down Expand Up @@ -295,7 +323,7 @@ async def execute_tools_and_side_effects(
)
elif (
not output_schema or output_schema.is_plain_text()
) and not processed_response.has_tools_to_run():
) and not processed_response.has_tools_or_approvals_to_run():
return await cls.execute_final_output(
agent=agent,
original_input=original_input,
Expand Down Expand Up @@ -343,10 +371,16 @@ def process_model_response(
run_handoffs = []
functions = []
computer_actions = []
mcp_approval_requests = []
tools_used: list[str] = []
handoff_map = {handoff.tool_name: handoff for handoff in handoffs}
function_map = {tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool)}
computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None)
hosted_mcp_server_map = {
tool.tool_config["server_label"]: tool
for tool in all_tools
if isinstance(tool, HostedMCPTool)
}

for output in response.output:
if isinstance(output, ResponseOutputMessage):
Expand Down Expand Up @@ -375,6 +409,34 @@ def process_model_response(
computer_actions.append(
ToolRunComputerAction(tool_call=output, computer_tool=computer_tool)
)
elif isinstance(output, McpApprovalRequest):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: inconsistent MCP casing

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comes from the SDK unfortunately. I gave the feedback but it wasnt taken into account

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's the SDK

items.append(MCPApprovalRequestItem(raw_item=output, agent=agent))
if output.server_label not in hosted_mcp_server_map:
_error_tracing.attach_error_to_current_span(
SpanError(
message="MCP server label not found",
data={"server_label": output.server_label},
)
)
raise ModelBehaviorError(f"MCP server label {output.server_label} not found")
else:
server = hosted_mcp_server_map[output.server_label]
if server.on_approval_request:
mcp_approval_requests.append(
ToolRunMCPApprovalRequest(
request_item=output,
mcp_tool=server,
)
)
else:
logger.warning(
f"MCP server {output.server_label} has no on_approval_request hook"
)
elif isinstance(output, McpListTools):
items.append(MCPListToolsItem(raw_item=output, agent=agent))
elif isinstance(output, McpCall):
items.append(ToolCallItem(raw_item=output, agent=agent))
tools_used.append(output.name)
elif not isinstance(output, ResponseFunctionToolCall):
logger.warning(f"Unexpected output type, ignoring: {type(output)}")
continue
Expand Down Expand Up @@ -417,6 +479,7 @@ def process_model_response(
functions=functions,
computer_actions=computer_actions,
tools_used=tools_used,
mcp_approval_requests=mcp_approval_requests,
)

@classmethod
Expand Down Expand Up @@ -643,6 +706,40 @@ async def execute_handoffs(
next_step=NextStepHandoff(new_agent),
)

@classmethod
async def execute_mcp_approval_requests(
cls,
*,
agent: Agent[TContext],
approval_requests: list[ToolRunMCPApprovalRequest],
context_wrapper: RunContextWrapper[TContext],
) -> list[RunItem]:
async def run_single_approval(approval_request: ToolRunMCPApprovalRequest) -> RunItem:
callback = approval_request.mcp_tool.on_approval_request
assert callback is not None, "Callback is required for MCP approval requests"
maybe_awaitable_result = callback(
MCPToolApprovalRequest(context_wrapper, approval_request.request_item)
)
if inspect.isawaitable(maybe_awaitable_result):
result = await maybe_awaitable_result
else:
result = maybe_awaitable_result
reason = result.get("reason", None)
raw_item: McpApprovalResponse = {
"approval_request_id": approval_request.request_item.id,
"approve": result["approve"],
"type": "mcp_approval_response",
}
if not result["approve"] and reason:
raw_item["reason"] = reason
return MCPApprovalResponseItem(
raw_item=raw_item,
agent=agent,
)

tasks = [run_single_approval(approval_request) for approval_request in approval_requests]
return await asyncio.gather(*tasks)

@classmethod
async def execute_final_output(
cls,
Expand Down Expand Up @@ -727,6 +824,11 @@ def stream_step_result_to_queue(
event = RunItemStreamEvent(item=item, name="tool_output")
elif isinstance(item, ReasoningItem):
event = RunItemStreamEvent(item=item, name="reasoning_item_created")
elif isinstance(item, MCPApprovalRequestItem):
event = RunItemStreamEvent(item=item, name="mcp_approval_requested")
elif isinstance(item, MCPListToolsItem):
event = RunItemStreamEvent(item=item, name="mcp_list_tools")

else:
logger.warning(f"Unexpected item type: {type(item)}")
event = None
Expand Down
Loading