From 28b974e63463799f6d2a981d15b6b0152de2393a Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 6 Aug 2025 17:03:41 -0700 Subject: [PATCH 01/39] Work in progress MCP call support --- .../contrib/openai_agents/_openai_runner.py | 8 +- temporalio/contrib/pydantic.py | 1 + tests/contrib/openai_agents/test_openai.py | 159 +++++++++++++++++- 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index 94a079fd5..d279f080c 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -54,10 +54,10 @@ async def run( "Provided tool is not a tool type. If using an activity, make sure to wrap it with openai_agents.workflow.activity_as_tool." ) - if starting_agent.mcp_servers: - raise ValueError( - "Temporal OpenAI agent does not support on demand MCP servers." - ) + # if starting_agent.mcp_servers: + # raise ValueError( + # "Temporal OpenAI agent does not support on demand MCP servers." + # ) # workaround for https://github.com/pydantic/pydantic/issues/9541 # ValidatorIterator returned diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index 97f1b6ac3..63c539139 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -95,6 +95,7 @@ def from_payload( See https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_json. """ + print(f"From_payload {payload} - type: {type_hint}") _type_hint = type_hint if type_hint is not None else Any return TypeAdapter(_type_hint).validate_json(payload.data) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index ed5e1ffa4..e40955c90 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -5,7 +5,7 @@ import uuid from dataclasses import dataclass from datetime import timedelta -from typing import Any, AsyncIterator, Optional, Union, no_type_check +from typing import Any, AsyncIterator, Optional, Union, no_type_check, Callable, Sequence import nexusrpc import pytest @@ -42,7 +42,7 @@ handoff, input_guardrail, output_guardrail, - trace, + trace, AgentBase, ) from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX from agents.items import ( @@ -51,6 +51,9 @@ ToolCallOutputItem, TResponseStreamEvent, ) +from agents.mcp import MCPServerStdio, MCPServer, MCPServerStdioParams +from mcp import GetPromptResult, ListPromptsResult, Tool as MCPTool +from mcp.types import CallToolResult from openai import APIStatusError, AsyncOpenAI, BaseModel from openai.types.responses import ( EasyInputMessageParam, @@ -88,6 +91,7 @@ from temporalio.contrib.pydantic import pydantic_data_converter from temporalio.exceptions import ApplicationError, CancelledError from temporalio.testing import WorkflowEnvironment +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions from tests.contrib.openai_agents.research_agents.research_manager import ( ResearchManager, ) @@ -2499,3 +2503,154 @@ async def test_hosted_mcp_tool(client: Client, use_local_model): result = await workflow_handle.result() if use_local_model: assert result == "Some language" + + +class TemporalMCPServerWorkflowShim(MCPServer): + def __init__(self, name: str): + self.server_name = name + super().__init__() + + @property + def name(self) -> str: + return self.server_name + + async def connect(self) -> None: + raise ValueError("Cannot connect to a server shim") + + async def cleanup(self) -> None: + raise ValueError("Cannot clean up a server shim") + + async def list_tools(self, run_context: Optional[RunContextWrapper[Any]] = None, + agent: Optional[AgentBase] = None) -> list[MCPTool]: + workflow.logger.info("Listing tools") + tools: list[MCPTool] = await workflow.execute_local_activity( + self.name + "-list-tools", + start_to_close_timeout=timedelta(seconds=30), + result_type=list[MCPTool], + ) + print(tools[0]) + print("Tool type:", type(tools[0])) + # print(type(MCPTool(**tools[0]))) + return tools + + async def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: + return await workflow.execute_local_activity( + self.name + "-call-tool", + args = [tool_name, arguments], + start_to_close_timeout=timedelta(seconds=30), + result_type=CallToolResult) + + async def list_prompts(self) -> ListPromptsResult: + raise NotImplementedError() + + async def get_prompt(self, name: str, arguments: Optional[dict[str, Any]] = None) -> GetPromptResult: + raise NotImplementedError() + + +class TemporalMCPServer(TemporalMCPServerWorkflowShim): + def __init__(self, server: MCPServer): + self.server = server + super().__init__(server.name) + + @property + def name(self) -> str: + return self.server.name + + async def connect(self) -> None: + await self.server.connect() + + async def cleanup(self) -> None: + await self.server.cleanup() + + async def list_tools(self, run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None) -> list[MCPTool]: + if not workflow.in_workflow(): + return await self.server.list_tools(run_context, agent) + + return await super().list_tools(run_context, agent) + + async def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: + if not workflow.in_workflow(): + return await self.server.call_tool(tool_name, arguments) + + return await super().call_tool(tool_name, arguments) + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.cleanup() + + def get_activities(self)-> Sequence[Callable]: + + @activity.defn(name=self.name + "-list-tools") + async def list_tools() -> list[MCPTool]: + activity.logger.info("Listing tools in activity") + return await self.server.list_tools() + + @activity.defn(name=self.name + "-call-tool") + async def call_tool(tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: + return await self.server.call_tool(tool_name, arguments) + + return list_tools, call_tool + +@workflow.defn +class McpServerWorkflow: + @workflow.run + async def run(self, question: str) -> str: + print("Running") + server: MCPServer = TemporalMCPServerWorkflowShim("Filesystem Server, via npx") + agent = Agent[str]( + name="MCP ServerWorkflow", + instructions="Use the tools to read the filesystem and answer questions based on those files.", + mcp_servers=[server], + ) + result = await Runner.run(starting_agent=agent, input=question) + return result.final_output + +async def test_mcp_server(client: Client): + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("No openai API key") + + new_config = client.config() + new_config["plugins"] = [ + openai_agents.OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=120) + ), + ) + ] + client = Client(**new_config) + + async with TemporalMCPServer(MCPServerStdio( + name="Filesystem Server, via npx", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", os.path.dirname(os.path.abspath(__file__))], + }, + )) as server: + async with TemporalMCPServer(MCPServerStdio( + name="Some other server", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__))], + }, + )) as server2: + async with new_worker( + client, + McpServerWorkflow, + activities=list(server.get_activities()) + list(server2.get_activities()), + workflow_runner=SandboxedWorkflowRunner(SandboxRestrictions.default.with_passthrough_all_modules()) + ) as worker: + print("Starting workflow") + workflow_handle = await client.start_workflow( + McpServerWorkflow.run, + "Read the files and list them.", + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + result = await workflow_handle.result() + print(result) + assert False \ No newline at end of file From c197efdd1238e1fa275958612ff8c6d5795ec40c Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 08:14:41 -0700 Subject: [PATCH 02/39] Moving mcp classes into plugin --- temporalio/contrib/openai_agents/__init__.py | 2 + temporalio/contrib/openai_agents/_mcp.py | 115 +++++++++++ .../openai_agents/_temporal_openai_agents.py | 36 +++- tests/contrib/openai_agents/test_openai.py | 187 ++++++------------ 4 files changed, 204 insertions(+), 136 deletions(-) create mode 100644 temporalio/contrib/openai_agents/_mcp.py diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index 274f5b98b..f50b19bc1 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -8,6 +8,7 @@ Use with caution in production environments. """ +from temporalio.contrib.openai_agents._mcp import TemporalMCPServerWorkflowShim from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._temporal_openai_agents import ( OpenAIAgentsPlugin, @@ -24,6 +25,7 @@ "OpenAIAgentsPlugin", "ModelActivityParameters", "workflow", + "TemporalMCPServerWorkflowShim", "TestModel", "TestModelProvider", ] diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py new file mode 100644 index 000000000..ce6ea15c3 --- /dev/null +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -0,0 +1,115 @@ +from datetime import timedelta +from typing import Any, Callable, Optional, Sequence + +from agents import AgentBase, RunContextWrapper +from agents.mcp import MCPServer +from mcp import GetPromptResult, ListPromptsResult +from mcp import Tool as MCPTool +from mcp.types import CallToolResult + +from temporalio import activity, workflow + + +class TemporalMCPServerWorkflowShim(MCPServer): + def __init__(self, name: str): + self.server_name = name + super().__init__() + + @property + def name(self) -> str: + return self.server_name + + async def connect(self) -> None: + raise ValueError("Cannot connect to a server shim") + + async def cleanup(self) -> None: + raise ValueError("Cannot clean up a server shim") + + async def list_tools( + self, + run_context: Optional[RunContextWrapper[Any]] = None, + agent: Optional[AgentBase] = None, + ) -> list[MCPTool]: + workflow.logger.info("Listing tools") + tools: list[MCPTool] = await workflow.execute_local_activity( + self.name + "-list-tools", + start_to_close_timeout=timedelta(seconds=30), + result_type=list[MCPTool], + ) + print(tools[0]) + print("Tool type:", type(tools[0])) + # print(type(MCPTool(**tools[0]))) + return tools + + async def call_tool( + self, tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + return await workflow.execute_local_activity( + self.name + "-call-tool", + args=[tool_name, arguments], + start_to_close_timeout=timedelta(seconds=30), + result_type=CallToolResult, + ) + + async def list_prompts(self) -> ListPromptsResult: + raise NotImplementedError() + + async def get_prompt( + self, name: str, arguments: Optional[dict[str, Any]] = None + ) -> GetPromptResult: + raise NotImplementedError() + + +class TemporalMCPServer(TemporalMCPServerWorkflowShim): + def __init__(self, server: MCPServer): + self.server = server + super().__init__(server.name) + + @property + def name(self) -> str: + return self.server.name + + async def connect(self) -> None: + await self.server.connect() + + async def cleanup(self) -> None: + await self.server.cleanup() + + async def list_tools( + self, + run_context: Optional[RunContextWrapper[Any]] = None, + agent: Optional[AgentBase] = None, + ) -> list[MCPTool]: + if not workflow.in_workflow(): + return await self.server.list_tools(run_context, agent) + + return await super().list_tools(run_context, agent) + + async def call_tool( + self, tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + if not workflow.in_workflow(): + return await self.server.call_tool(tool_name, arguments) + + return await super().call_tool(tool_name, arguments) + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.cleanup() + + def get_activities(self) -> Sequence[Callable]: + @activity.defn(name=self.name + "-list-tools") + async def list_tools() -> list[MCPTool]: + activity.logger.info("Listing tools in activity") + return await self.server.list_tools() + + @activity.defn(name=self.name + "-call-tool") + async def call_tool( + tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + return await self.server.call_tool(tool_name, arguments) + + return list_tools, call_tool diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index a1f71db71..451efab79 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,8 +1,9 @@ """Initialize Temporal OpenAI Agents overrides.""" -from contextlib import contextmanager +import dataclasses +from contextlib import AsyncExitStack, contextmanager from datetime import timedelta -from typing import AsyncIterator, Callable, Optional, Union +from typing import AsyncIterator, Callable, Optional, Sequence, Union from agents import ( AgentOutputSchemaBase, @@ -17,6 +18,7 @@ set_trace_provider, ) from agents.items import TResponseStreamEvent +from agents.mcp import MCPServer from agents.run import get_default_agent_runner, set_default_agent_runner from agents.tracing import get_trace_provider from agents.tracing.provider import DefaultTraceProvider @@ -26,6 +28,7 @@ import temporalio.worker from temporalio.client import ClientConfig from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity +from temporalio.contrib.openai_agents._mcp import TemporalMCPServer from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -42,6 +45,7 @@ DataConverter, ) from temporalio.worker import Worker, WorkerConfig +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner @contextmanager @@ -203,6 +207,7 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, + mcp_servers: Sequence[MCPServer] = (), ) -> None: """Initialize the OpenAI agents plugin. @@ -231,6 +236,13 @@ def __init__( self._model_params = model_params self._model_provider = model_provider + self._mcp_servers = [ + server + if isinstance(server, TemporalMCPServer) + else TemporalMCPServer(server) + for server in mcp_servers + ] + def configure_client(self, config: ClientConfig) -> ClientConfig: """Configure the Temporal client for OpenAI agents integration. @@ -265,9 +277,18 @@ def configure_worker(self, config: WorkerConfig) -> WorkerConfig: config["interceptors"] = list(config.get("interceptors") or []) + [ OpenAIAgentsTracingInterceptor() ] - config["activities"] = list(config.get("activities") or []) + [ - ModelActivity(self._model_provider).invoke_model_activity - ] + new_activities = [ModelActivity(self._model_provider).invoke_model_activity] + for mcp_server in self._mcp_servers: + new_activities.extend(mcp_server.get_activities()) + config["activities"] = list(config.get("activities") or []) + new_activities + + runner = config.get("workflow_runner") + if isinstance(runner, SandboxedWorkflowRunner): + config["workflow_runner"] = dataclasses.replace( + runner, + restrictions=runner.restrictions.with_passthrough_modules("mcp"), + ) + return super().configure_worker(config) async def run_worker(self, worker: Worker) -> None: @@ -281,4 +302,7 @@ async def run_worker(self, worker: Worker) -> None: worker: The worker instance to run. """ with set_open_ai_agent_temporal_overrides(self._model_params): - await super().run_worker(worker) + async with AsyncExitStack() as stack: + for mcp_server in self._mcp_servers: + await stack.enter_async_context(mcp_server) + await super().run_worker(worker) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index e40955c90..43da7c5a4 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -5,12 +5,21 @@ import uuid from dataclasses import dataclass from datetime import timedelta -from typing import Any, AsyncIterator, Optional, Union, no_type_check, Callable, Sequence +from typing import ( + Any, + AsyncIterator, + Callable, + Optional, + Sequence, + Union, + no_type_check, +) import nexusrpc import pytest from agents import ( Agent, + AgentBase, AgentOutputSchemaBase, CodeInterpreterTool, FileSearchTool, @@ -42,7 +51,7 @@ handoff, input_guardrail, output_guardrail, - trace, AgentBase, + trace, ) from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX from agents.items import ( @@ -51,9 +60,7 @@ ToolCallOutputItem, TResponseStreamEvent, ) -from agents.mcp import MCPServerStdio, MCPServer, MCPServerStdioParams -from mcp import GetPromptResult, ListPromptsResult, Tool as MCPTool -from mcp.types import CallToolResult +from agents.mcp import MCPServer, MCPServerStdio from openai import APIStatusError, AsyncOpenAI, BaseModel from openai.types.responses import ( EasyInputMessageParam, @@ -77,21 +84,24 @@ from openai.types.responses.response_prompt_param import ResponsePromptParam from pydantic import ConfigDict, Field, TypeAdapter -import temporalio.api.cloud.namespace.v1 from temporalio import activity, workflow from temporalio.client import Client, WorkflowFailureError, WorkflowHandle -from temporalio.common import RetryPolicy, SearchAttributeValueType +from temporalio.common import RetryPolicy from temporalio.contrib import openai_agents from temporalio.contrib.openai_agents import ( ModelActivityParameters, + TemporalMCPServerWorkflowShim, TestModel, TestModelProvider, ) from temporalio.contrib.openai_agents._temporal_model_stub import _extract_summary from temporalio.contrib.pydantic import pydantic_data_converter -from temporalio.exceptions import ApplicationError, CancelledError +from temporalio.exceptions import CancelledError from temporalio.testing import WorkflowEnvironment -from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions +from temporalio.worker.workflow_sandbox import ( + SandboxedWorkflowRunner, + SandboxRestrictions, +) from tests.contrib.openai_agents.research_agents.research_manager import ( ResearchManager, ) @@ -2505,95 +2515,6 @@ async def test_hosted_mcp_tool(client: Client, use_local_model): assert result == "Some language" -class TemporalMCPServerWorkflowShim(MCPServer): - def __init__(self, name: str): - self.server_name = name - super().__init__() - - @property - def name(self) -> str: - return self.server_name - - async def connect(self) -> None: - raise ValueError("Cannot connect to a server shim") - - async def cleanup(self) -> None: - raise ValueError("Cannot clean up a server shim") - - async def list_tools(self, run_context: Optional[RunContextWrapper[Any]] = None, - agent: Optional[AgentBase] = None) -> list[MCPTool]: - workflow.logger.info("Listing tools") - tools: list[MCPTool] = await workflow.execute_local_activity( - self.name + "-list-tools", - start_to_close_timeout=timedelta(seconds=30), - result_type=list[MCPTool], - ) - print(tools[0]) - print("Tool type:", type(tools[0])) - # print(type(MCPTool(**tools[0]))) - return tools - - async def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: - return await workflow.execute_local_activity( - self.name + "-call-tool", - args = [tool_name, arguments], - start_to_close_timeout=timedelta(seconds=30), - result_type=CallToolResult) - - async def list_prompts(self) -> ListPromptsResult: - raise NotImplementedError() - - async def get_prompt(self, name: str, arguments: Optional[dict[str, Any]] = None) -> GetPromptResult: - raise NotImplementedError() - - -class TemporalMCPServer(TemporalMCPServerWorkflowShim): - def __init__(self, server: MCPServer): - self.server = server - super().__init__(server.name) - - @property - def name(self) -> str: - return self.server.name - - async def connect(self) -> None: - await self.server.connect() - - async def cleanup(self) -> None: - await self.server.cleanup() - - async def list_tools(self, run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None) -> list[MCPTool]: - if not workflow.in_workflow(): - return await self.server.list_tools(run_context, agent) - - return await super().list_tools(run_context, agent) - - async def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: - if not workflow.in_workflow(): - return await self.server.call_tool(tool_name, arguments) - - return await super().call_tool(tool_name, arguments) - - async def __aenter__(self): - await self.connect() - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - await self.cleanup() - - def get_activities(self)-> Sequence[Callable]: - - @activity.defn(name=self.name + "-list-tools") - async def list_tools() -> list[MCPTool]: - activity.logger.info("Listing tools in activity") - return await self.server.list_tools() - - @activity.defn(name=self.name + "-call-tool") - async def call_tool(tool_name: str, arguments: Optional[dict[str, Any]]) -> CallToolResult: - return await self.server.call_tool(tool_name, arguments) - - return list_tools, call_tool - @workflow.defn class McpServerWorkflow: @workflow.run @@ -2608,49 +2529,55 @@ async def run(self, question: str) -> str: result = await Runner.run(starting_agent=agent, input=question) return result.final_output + async def test_mcp_server(client: Client): if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") + server = MCPServerStdio( + name="Filesystem Server, via npx", + params={ + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__)), + ], + }, + ) + server2 = MCPServerStdio( + name="Some other server", + params={ + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__)), + ], + }, + ) new_config = client.config() new_config["plugins"] = [ openai_agents.OpenAIAgentsPlugin( model_params=ModelActivityParameters( start_to_close_timeout=timedelta(seconds=120) ), + mcp_servers=[server, server2], ) ] client = Client(**new_config) - async with TemporalMCPServer(MCPServerStdio( - name="Filesystem Server, via npx", - params={ - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", os.path.dirname(os.path.abspath(__file__))], - }, - )) as server: - async with TemporalMCPServer(MCPServerStdio( - name="Some other server", - params={ - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", - os.path.dirname(os.path.abspath(__file__))], - }, - )) as server2: - async with new_worker( - client, - McpServerWorkflow, - activities=list(server.get_activities()) + list(server2.get_activities()), - workflow_runner=SandboxedWorkflowRunner(SandboxRestrictions.default.with_passthrough_all_modules()) - ) as worker: - print("Starting workflow") - workflow_handle = await client.start_workflow( - McpServerWorkflow.run, - "Read the files and list them.", - id=f"mcp-server-{uuid.uuid4()}", - task_queue=worker.task_queue, - execution_timeout=timedelta(seconds=30), - ) - result = await workflow_handle.result() - print(result) - assert False \ No newline at end of file + async with new_worker( + client, + McpServerWorkflow, + ) as worker: + workflow_handle = await client.start_workflow( + McpServerWorkflow.run, + "Read the files and list them.", + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + result = await workflow_handle.result() + print(result) + assert False From 8fff2aa4b1204e61f0dfe0f9b517bb036bc00873 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 09:25:53 -0700 Subject: [PATCH 03/39] Updated documentation --- temporalio/contrib/openai_agents/_mcp.py | 70 +++++++++++-------- .../openai_agents/_temporal_openai_agents.py | 25 ++++++- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 6e69838af..e7309100a 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -178,31 +178,35 @@ async def get_prompt( class TemporalMCPServer(TemporalMCPServerWorkflowShim): """Concrete implementation of a Temporal-compatible MCP server wrapper. - - This class only needs to be used if you want to manage the server lifetime manually. - Otherwise, you can simply provide an MCPServer to the OpenAIAgentsPlugin, which will - bind the server to the lifetime of the worker. + + **Recommended Usage**: Instead of manually managing TemporalMCPServer instances, + use the `mcp_servers` parameter of OpenAIAgentsPlugin, which automatically handles + server wrapping, activity registration, and lifecycle management. + + This class only needs to be used if you want to manage the server lifetime manually + or need fine-grained control over activity registration. For most use cases, passing + your MCP servers directly to OpenAIAgentsPlugin is simpler and more robust. This class wraps an actual MCP server instance and provides both workflow-safe and non-workflow execution modes. When used outside of a workflow context, it delegates directly to the wrapped server. When used within a workflow, it delegates to the parent shim class which executes operations via Temporal activities to maintain determinism. - + This dual-mode operation allows the same server instance to be used both in workflow and non-workflow contexts, making it convenient for testing and mixed-mode applications. - + The class also provides activity definitions that can be registered with a Temporal worker to enable the workflow-safe operations. - + Args: server: The actual MCP server instance to wrap. """ - + def __init__(self, server: MCPServer): """Initialize the Temporal MCP server wrapper. - + Args: server: The actual MCP server instance to wrap and delegate to. """ @@ -212,7 +216,7 @@ def __init__(self, server: MCPServer): @property def name(self) -> str: """Get the name of the wrapped MCP server. - + Returns: The name of the underlying MCP server instance. """ @@ -220,7 +224,7 @@ def name(self) -> str: async def connect(self) -> None: """Connect to the underlying MCP server. - + Delegates the connection operation to the wrapped server instance. This method can be called directly when not in a workflow context. """ @@ -228,7 +232,7 @@ async def connect(self) -> None: async def cleanup(self) -> None: """Clean up the underlying MCP server connection. - + Delegates the cleanup operation to the wrapped server instance. This method can be called directly when not in a workflow context. """ @@ -240,15 +244,15 @@ async def list_tools( agent: Optional[AgentBase] = None, ) -> list[MCPTool]: """List available tools with context-aware execution. - + When called outside a workflow context, delegates directly to the wrapped server. When called within a workflow, delegates to the parent shim class which executes the operation via a Temporal activity. - + Args: run_context: Optional runtime context wrapper for the operation. agent: Optional agent instance that may be relevant to tool listing. - + Returns: A list of MCP tools available from the server. """ @@ -261,15 +265,15 @@ async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: """Execute a tool with context-aware execution. - + When called outside a workflow context, delegates directly to the wrapped server. When called within a workflow, delegates to the parent shim class which executes the operation via a Temporal activity. - + Args: tool_name: The name of the tool to execute. arguments: Optional dictionary of arguments to pass to the tool. - + Returns: The result of the tool execution. """ @@ -280,7 +284,7 @@ async def call_tool( async def __aenter__(self): """Async context manager entry - connects to the MCP server. - + Returns: Self, allowing the server to be used within the context. """ @@ -289,7 +293,7 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_value, traceback): """Async context manager exit - cleans up the MCP server connection. - + Args: exc_type: Exception type, if any exception occurred. exc_value: Exception value, if any exception occurred. @@ -299,30 +303,38 @@ async def __aexit__(self, exc_type, exc_value, traceback): def get_activities(self) -> Sequence[Callable]: """Get Temporal activity functions for workflow-safe MCP operations. - + This method creates and returns Temporal activity functions that delegate to the wrapped MCP server. These activities should be registered with a Temporal worker to enable workflow-safe execution of MCP operations. - + The returned activities include: - {name}-list-tools: Lists available tools - {name}-call-tool: Executes a specific tool - {name}-list-prompts: Lists available prompts - {name}-get-prompt: Retrieves a specific prompt - + Returns: A sequence of activity functions to register with a Temporal worker. - + Example: ```python + # Manual management (not recommended for most cases) async with TemporalMCPServer(my_mcp_server) as server: async with Worker( client, activities=server.get_activities(), ) as worker: ... + + # Recommended: Use OpenAIAgentsPlugin instead + plugin = OpenAIAgentsPlugin(mcp_servers=[my_mcp_server]) + client = await Client.connect("localhost:7233", plugins=[plugin]) + async with Worker(client, task_queue="my-queue", workflows=[MyWorkflow]): + ... ``` """ + @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: activity.logger.info("Listing tools in activity") @@ -348,11 +360,11 @@ async def get_prompt( async def list_prompts(self) -> ListPromptsResult: """List available prompts with context-aware execution. - + When called outside a workflow context, delegates directly to the wrapped server. When called within a workflow, delegates to the parent shim class which executes the operation via a Temporal activity. - + Returns: A list of available prompts from the server. """ @@ -365,15 +377,15 @@ async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: """Get a specific prompt with context-aware execution. - + When called outside a workflow context, delegates directly to the wrapped server. When called within a workflow, delegates to the parent shim class which executes the operation via a Temporal activity. - + Args: name: The name of the prompt to retrieve. arguments: Optional arguments for the prompt. - + Returns: The requested prompt from the server. """ diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index f012f9a7c..db22937b4 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -174,18 +174,24 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): 1. Configures the Pydantic data converter for type-safe serialization 2. Sets up tracing interceptors for OpenAI agent interactions 3. Registers model execution activities - 4. Manages the OpenAI agent runtime overrides during worker execution + 4. Automatically registers MCP server activities and manages their lifecycles + 5. Manages the OpenAI agent runtime overrides during worker execution Args: model_params: Configuration parameters for Temporal activity execution of model calls. If None, default parameters will be used. model_provider: Optional model provider for custom model implementations. Useful for testing or custom model integrations. + mcp_servers: Sequence of MCP servers to automatically register with the worker. + The plugin will wrap each server in a TemporalMCPServer if needed and + manage their connection lifecycles tied to the worker lifetime. This is + the recommended way to use MCP servers with Temporal workflows. Example: >>> from temporalio.client import Client >>> from temporalio.worker import Worker >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters + >>> from agents.mcp import MCPServerStdio >>> from datetime import timedelta >>> >>> # Configure model parameters @@ -194,8 +200,17 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): ... retry_policy=RetryPolicy(maximum_attempts=3) ... ) >>> - >>> # Create plugin - >>> plugin = OpenAIAgentsPlugin(model_params=model_params) + >>> # Create MCP servers + >>> filesystem_server = MCPServerStdio( + ... name="Filesystem Server", + ... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]} + ... ) + >>> + >>> # Create plugin with MCP servers + >>> plugin = OpenAIAgentsPlugin( + ... model_params=model_params, + ... mcp_servers=[filesystem_server] + ... ) >>> >>> # Use with client and worker >>> client = await Client.connect( @@ -222,6 +237,10 @@ def __init__( of model calls. If None, default parameters will be used. model_provider: Optional model provider for custom model implementations. Useful for testing or custom model integrations. + mcp_servers: Sequence of MCP servers to automatically register with the worker. + Each server will be wrapped in a TemporalMCPServer if not already wrapped, + and their activities will be automatically registered with the worker. + The plugin manages the connection lifecycle of these servers. """ if model_params is None: model_params = ModelActivityParameters() From 19e57ebe761e962d2cb577f6ffc591a5d33d404f Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 09:41:17 -0700 Subject: [PATCH 04/39] Add local test --- .../openai_agents/_temporal_openai_agents.py | 4 + tests/contrib/openai_agents/test_openai.py | 80 ++++++++++++++++--- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index db22937b4..367106bbf 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -104,6 +104,8 @@ def set_open_ai_agent_temporal_overrides( class TestModelProvider(ModelProvider): """Test model provider which simply returns the given module.""" + __test__ = False + def __init__(self, model: Model): """Initialize a test model provider with a model.""" self._model = model @@ -116,6 +118,8 @@ def get_model(self, model_name: Union[str, None]) -> Model: class TestModel(Model): """Test model for use mocking model responses.""" + __test__ = False + def __init__(self, fn: Callable[[], ModelResponse]) -> None: """Initialize a test model with a callable.""" self.fn = fn diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 43da7c5a4..0d9c45788 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -8,9 +8,7 @@ from typing import ( Any, AsyncIterator, - Callable, Optional, - Sequence, Union, no_type_check, ) @@ -19,7 +17,6 @@ import pytest from agents import ( Agent, - AgentBase, AgentOutputSchemaBase, CodeInterpreterTool, FileSearchTool, @@ -29,7 +26,6 @@ ImageGenerationTool, InputGuardrailTripwireTriggered, ItemHelpers, - LocalShellTool, MCPToolApprovalFunctionResult, MCPToolApprovalRequest, MessageOutputItem, @@ -58,6 +54,7 @@ HandoffOutputItem, ToolCallItem, ToolCallOutputItem, + TResponseOutputItem, TResponseStreamEvent, ) from agents.mcp import MCPServer, MCPServerStdio @@ -98,10 +95,6 @@ from temporalio.contrib.pydantic import pydantic_data_converter from temporalio.exceptions import CancelledError from temporalio.testing import WorkflowEnvironment -from temporalio.worker.workflow_sandbox import ( - SandboxedWorkflowRunner, - SandboxRestrictions, -) from tests.contrib.openai_agents.research_agents.research_manager import ( ResearchManager, ) @@ -2530,7 +2523,69 @@ async def run(self, question: str) -> str: return result.final_output -async def test_mcp_server(client: Client): +class ResponseBuilders: + @staticmethod + def model_response(output: TResponseOutputItem) -> ModelResponse: + return ModelResponse( + output=[output], + usage=Usage(), + response_id=None, + ) + + @staticmethod + def tool_call(arguments: str, name: str) -> ModelResponse: + return ResponseBuilders.model_response( + ResponseFunctionToolCall( + arguments=arguments, + call_id="call", + name=name, + type="function_call", + id="id", + status="completed", + ) + ) + + @staticmethod + def output_message(text: str) -> ModelResponse: + return ResponseBuilders.model_response( + ResponseOutputMessage( + id="", + content=[ + ResponseOutputText( + text=text, + annotations=[], + type="output_text", + ) + ], + role="assistant", + status="completed", + type="message", + ) + ) + + +class McpServerModel(StaticTestModel): + responses = [ + ResponseBuilders.tool_call( + arguments='{"path":"/"}', + name="list_directory", + ), + ResponseBuilders.tool_call( + arguments="{}", + name="list_allowed_directories", + ), + ResponseBuilders.tool_call( + arguments='{"path":"."}', + name="list_directory", + ), + ResponseBuilders.output_message( + "Here are the files and directories in the allowed path." + ), + ] + + +@pytest.mark.parametrize("use_local_model", [True, False]) +async def test_mcp_server(client: Client, use_local_model: bool): if not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") @@ -2562,6 +2617,9 @@ async def test_mcp_server(client: Client): model_params=ModelActivityParameters( start_to_close_timeout=timedelta(seconds=120) ), + model_provider=TestModelProvider(McpServerModel()) + if use_local_model + else None, mcp_servers=[server, server2], ) ] @@ -2579,5 +2637,5 @@ async def test_mcp_server(client: Client): execution_timeout=timedelta(seconds=30), ) result = await workflow_handle.result() - print(result) - assert False + if use_local_model: + assert result == "Here are the files and directories in the allowed path." From c0a280c04979c5814bf5f6d6e75b62bb8f17f315 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 10:32:12 -0700 Subject: [PATCH 05/39] Fix problems since MCP doesn't exist on python 3.9 --- pyproject.toml | 3 ++- temporalio/contrib/openai_agents/__init__.py | 13 +++++++++---- temporalio/contrib/openai_agents/_mcp.py | 6 +++--- .../openai_agents/_temporal_openai_agents.py | 10 +++++++--- tests/contrib/openai_agents/test_openai.py | 13 +++++++++---- uv.lock | 2 ++ 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2d3f96068..42f0d71ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ opentelemetry = [ pydantic = ["pydantic>=2.0.0,<3"] openai-agents = [ "openai-agents >= 0.2.3,<0.3", - "eval-type-backport>=0.2.2; python_version < '3.10'" + "eval-type-backport>=0.2.2; python_version < '3.10'", + "mcp>=1.9.4, <2; python_version >= '3.10'", ] [project.urls] diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index fe088ae86..17c3af092 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -8,10 +8,15 @@ Use with caution in production environments. """ -from temporalio.contrib.openai_agents._mcp import ( - TemporalMCPServer, - TemporalMCPServerWorkflowShim, -) +# Best Effort mcp, as it is not supported on Python 3.9 +try: + from temporalio.contrib.openai_agents._mcp import ( + TemporalMCPServer, + TemporalMCPServerWorkflowShim, + ) +except ImportError: + pass + from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._temporal_openai_agents import ( OpenAIAgentsPlugin, diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index e7309100a..1a7260e31 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -3,9 +3,9 @@ from agents import AgentBase, RunContextWrapper from agents.mcp import MCPServer -from mcp import GetPromptResult, ListPromptsResult -from mcp import Tool as MCPTool -from mcp.types import CallToolResult +from mcp import GetPromptResult, ListPromptsResult # type:ignore +from mcp import Tool as MCPTool # type:ignore +from mcp.types import CallToolResult # type:ignore from temporalio import activity, workflow from temporalio.workflow import LocalActivityConfig diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 367106bbf..045d139c6 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,6 +1,7 @@ """Initialize Temporal OpenAI Agents overrides.""" import dataclasses +import typing from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from datetime import timedelta from typing import AsyncIterator, Callable, Optional, Sequence, Union @@ -18,7 +19,6 @@ set_trace_provider, ) from agents.items import TResponseStreamEvent -from agents.mcp import MCPServer from agents.run import get_default_agent_runner, set_default_agent_runner from agents.tracing import get_trace_provider from agents.tracing.provider import DefaultTraceProvider @@ -28,7 +28,6 @@ import temporalio.worker from temporalio.client import ClientConfig from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity -from temporalio.contrib.openai_agents._mcp import TemporalMCPServer from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -53,6 +52,11 @@ ) from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner +if typing.TYPE_CHECKING: + from agents.mcp import MCPServer + + from temporalio.contrib.openai_agents._mcp import TemporalMCPServer + @contextmanager def set_open_ai_agent_temporal_overrides( @@ -232,7 +236,7 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence[MCPServer] = (), + mcp_servers: Sequence["MCPServer"] = (), ) -> None: """Initialize the OpenAI agents plugin. diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 0d9c45788..01a46617c 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -57,7 +57,6 @@ TResponseOutputItem, TResponseStreamEvent, ) -from agents.mcp import MCPServer, MCPServerStdio from openai import APIStatusError, AsyncOpenAI, BaseModel from openai.types.responses import ( EasyInputMessageParam, @@ -87,7 +86,6 @@ from temporalio.contrib import openai_agents from temporalio.contrib.openai_agents import ( ModelActivityParameters, - TemporalMCPServerWorkflowShim, TestModel, TestModelProvider, ) @@ -2512,7 +2510,10 @@ async def test_hosted_mcp_tool(client: Client, use_local_model): class McpServerWorkflow: @workflow.run async def run(self, question: str) -> str: - print("Running") + from agents.mcp import MCPServer + + from temporalio.contrib.openai_agents import TemporalMCPServerWorkflowShim + server: MCPServer = TemporalMCPServerWorkflowShim("Filesystem Server, via npx") agent = Agent[str]( name="MCP ServerWorkflow", @@ -2586,9 +2587,13 @@ class McpServerModel(StaticTestModel): @pytest.mark.parametrize("use_local_model", [True, False]) async def test_mcp_server(client: Client, use_local_model: bool): - if not os.environ.get("OPENAI_API_KEY"): + if not use_local_model and not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") + if sys.version_info < (3, 10): + pytest.skip("Mcp not supported on Python 3.9") + from agents.mcp import MCPServer, MCPServerStdio + server = MCPServerStdio( name="Filesystem Server, via npx", params={ diff --git a/uv.lock b/uv.lock index 13cb7bed3..f018087dd 100644 --- a/uv.lock +++ b/uv.lock @@ -2721,6 +2721,7 @@ grpc = [ ] openai-agents = [ { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, + { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "openai-agents" }, ] opentelemetry = [ @@ -2758,6 +2759,7 @@ dev = [ requires-dist = [ { name = "eval-type-backport", marker = "python_full_version < '3.10' and extra == 'openai-agents'", specifier = ">=0.2.2" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.48.2,<2" }, + { name = "mcp", marker = "python_full_version >= '3.10' and extra == 'openai-agents'", specifier = ">=1.9.4,<2" }, { name = "nexus-rpc", specifier = "==1.1.0" }, { name = "openai-agents", marker = "extra == 'openai-agents'", specifier = ">=0.2.3,<0.3" }, { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.11.1,<2" }, From f2dcb6105d7eee70c20d7c9ed2a2a9137d976b0b Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 10:33:32 -0700 Subject: [PATCH 06/39] Remove debug log --- temporalio/contrib/pydantic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index 63c539139..97f1b6ac3 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -95,7 +95,6 @@ def from_payload( See https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_json. """ - print(f"From_payload {payload} - type: {type_hint}") _type_hint = type_hint if type_hint is not None else Any return TypeAdapter(_type_hint).validate_json(payload.data) From 2b1e0ac2977720a36ae8d8a8491ec6abfef4fead Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 11:06:41 -0700 Subject: [PATCH 07/39] Best effort import temporal server --- .../contrib/openai_agents/_temporal_openai_agents.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 045d139c6..8feb34e94 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -52,11 +52,15 @@ ) from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner +# MCP only supported on python >=3.10 +try: + from temporalio.contrib.openai_agents._mcp import TemporalMCPServer +except ImportError: + pass + if typing.TYPE_CHECKING: from agents.mcp import MCPServer - from temporalio.contrib.openai_agents._mcp import TemporalMCPServer - @contextmanager def set_open_ai_agent_temporal_overrides( From e80cdf72f466cb8d4aad8925bbee4c290131dc5f Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 7 Aug 2025 12:41:06 -0700 Subject: [PATCH 08/39] Delay import of mcp servers --- .../openai_agents/_temporal_openai_agents.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 8feb34e94..e05464a9a 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -52,15 +52,11 @@ ) from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner -# MCP only supported on python >=3.10 -try: - from temporalio.contrib.openai_agents._mcp import TemporalMCPServer -except ImportError: - pass - if typing.TYPE_CHECKING: from agents.mcp import MCPServer + from temporalio.contrib.openai_agents._mcp import TemporalMCPServer + @contextmanager def set_open_ai_agent_temporal_overrides( @@ -273,12 +269,18 @@ def __init__( self._model_params = model_params self._model_provider = model_provider - self._mcp_servers = [ - server - if isinstance(server, TemporalMCPServer) - else TemporalMCPServer(server) - for server in mcp_servers - ] + if mcp_servers: + # Delayed import as mcp servers only work on python >=3.10 + from temporalio.contrib.openai_agents._mcp import TemporalMCPServer + + self._mcp_servers = [ + server + if isinstance(server, TemporalMCPServer) + else TemporalMCPServer(server) + for server in mcp_servers + ] + else: + self._mcp_servers = [] def init_client_plugin(self, next: temporalio.client.Plugin) -> None: """Set the next client plugin""" From 0ed952ab95c907695a3d544455383e51712881ad Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 11 Aug 2025 09:24:03 -0700 Subject: [PATCH 09/39] Change to a stateless implementation --- temporalio/contrib/openai_agents/_mcp.py | 350 ++---------------- .../openai_agents/_temporal_openai_agents.py | 32 +- tests/contrib/openai_agents/test_openai.py | 28 +- 3 files changed, 52 insertions(+), 358 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 1a7260e31..46fcef096 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Any, Callable, Optional, Sequence +from typing import Any, Callable, Optional, Sequence, Union from agents import AgentBase, RunContextWrapper from agents.mcp import MCPServer @@ -8,122 +8,41 @@ from mcp.types import CallToolResult # type:ignore from temporalio import activity, workflow -from temporalio.workflow import LocalActivityConfig +from temporalio.workflow import ActivityConfig - -class TemporalMCPServerWorkflowShim(MCPServer): - """Workflow-compatible shim for MCP (Model Context Protocol) servers. - - This class provides a Temporal workflow-safe interface for interacting with MCP servers. - Instead of directly connecting to external MCP servers (which would violate workflow - determinism), this shim delegates tool operations to Temporal activities that can - safely perform non-deterministic operations. - - The shim is designed to be used within Temporal workflows where direct network - communication with external services is not allowed. It converts MCP server - operations into local activity executions that maintain workflow determinism. - - Args: - name: A descriptive name for the MCP server being shimmed. - config: Optional LocalActivityConfig for customizing activity execution timeouts - and other settings. Defaults to 1 minute timeout if not provided. - - Note: - This is a base shim class that cannot actually connect to real MCP servers. - Use TemporalMCPServer for wrapping actual MCP server instances. - """ - - def __init__(self, name: str, *, config: Optional[LocalActivityConfig] = None): - """Initialize the MCP server workflow shim. - - Args: - name: A descriptive name for the MCP server being shimmed. - config: Optional LocalActivityConfig for customizing activity execution. - If not provided, defaults to a 1-minute start_to_close_timeout. - """ - self.server_name = name - self.config = config or LocalActivityConfig( - start_to_close_timeout=timedelta(minutes=1), - ) +class StatelessTemporalMCPServer(MCPServer): + def __init__(self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None): + self.server = server if isinstance(server, MCPServer) else None + self._name = (server if isinstance(server, str) else server.name) + "-stateless" + self.config = config or ActivityConfig(start_to_close_timeout=timedelta(minutes=1)) super().__init__() @property def name(self) -> str: - """Get the name of the MCP server. - - Returns: - The descriptive name of the MCP server being shimmed. - """ - return self.server_name + return self._name async def connect(self) -> None: - """Attempt to connect to the MCP server. - - Raises: - ValueError: Always raised as this shim cannot connect to real servers. - Use TemporalMCPServer for actual server connections. - """ - raise ValueError("Cannot connect to a server shim") + pass async def cleanup(self) -> None: - """Attempt to clean up the MCP server connection. - - Raises: - ValueError: Always raised as this shim cannot clean up real servers. - Use TemporalMCPServer for actual server cleanup. - """ - raise ValueError("Cannot clean up a server shim") + pass async def list_tools( self, run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: - """List available tools from the MCP server via Temporal activity. - - This method executes a local activity to retrieve the list of available tools - from the underlying MCP server. The activity execution ensures workflow - determinism while allowing the actual tool listing to occur outside the - workflow sandbox. - - Args: - run_context: Optional runtime context wrapper for the operation. - agent: Optional agent instance that may be relevant to tool listing. - - Returns: - A list of MCP tools available from the server. - - Note: - This method must be called from within a Temporal workflow context. - """ - workflow.logger.info("Listing tools") - tools: list[MCPTool] = await workflow.execute_local_activity( + return await workflow.execute_activity( self.name + "-list-tools", + args=[], result_type=list[MCPTool], **self.config, ) - return tools async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - """Execute a tool call via Temporal activity. - - This method executes a local activity to call the specified tool on the - underlying MCP server. The activity execution ensures workflow determinism - while allowing the actual tool execution to occur outside the workflow sandbox. - - Args: - tool_name: The name of the tool to execute. - arguments: Optional dictionary of arguments to pass to the tool. - - Returns: - The result of the tool execution. - - Note: - This method must be called from within a Temporal workflow context. - """ - return await workflow.execute_local_activity( + return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], result_type=CallToolResult, @@ -131,21 +50,9 @@ async def call_tool( ) async def list_prompts(self) -> ListPromptsResult: - """List available prompts from the MCP server via Temporal activity. - - This method executes a local activity to retrieve the list of available prompts - from the underlying MCP server. The activity execution ensures workflow - determinism while allowing the actual prompt listing to occur outside the - workflow sandbox. - - Returns: - A list of available prompts from the server. - - Note: - This method must be called from within a Temporal workflow context. - """ - return await workflow.execute_local_activity( + return await workflow.execute_activity( self.name + "-list-prompts", + args=[], result_type=ListPromptsResult, **self.config, ) @@ -153,243 +60,40 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: - """Get a specific prompt from the MCP server via Temporal activity. - - This method executes a local activity to retrieve a specific prompt from the - underlying MCP server. The activity execution ensures workflow determinism - while allowing the actual prompt retrieval to occur outside the workflow sandbox. - - Args: - name: The name of the prompt to retrieve. - arguments: Optional arguments for the prompt. - - Returns: - The requested prompt from the server. - - Note: - This method must be called from within a Temporal workflow context. - """ - return await workflow.execute_local_activity( + return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], + result_type=GetPromptResult, **self.config, ) - -class TemporalMCPServer(TemporalMCPServerWorkflowShim): - """Concrete implementation of a Temporal-compatible MCP server wrapper. - - **Recommended Usage**: Instead of manually managing TemporalMCPServer instances, - use the `mcp_servers` parameter of OpenAIAgentsPlugin, which automatically handles - server wrapping, activity registration, and lifecycle management. - - This class only needs to be used if you want to manage the server lifetime manually - or need fine-grained control over activity registration. For most use cases, passing - your MCP servers directly to OpenAIAgentsPlugin is simpler and more robust. - - This class wraps an actual MCP server instance and provides both workflow-safe - and non-workflow execution modes. When used outside of a workflow context, - it delegates directly to the wrapped server. When used within a workflow, - it delegates to the parent shim class which executes operations via Temporal - activities to maintain determinism. - - This dual-mode operation allows the same server instance to be used both - in workflow and non-workflow contexts, making it convenient for testing - and mixed-mode applications. - - The class also provides activity definitions that can be registered with - a Temporal worker to enable the workflow-safe operations. - - Args: - server: The actual MCP server instance to wrap. - """ - - def __init__(self, server: MCPServer): - """Initialize the Temporal MCP server wrapper. - - Args: - server: The actual MCP server instance to wrap and delegate to. - """ - self.server = server - super().__init__(server.name) - - @property - def name(self) -> str: - """Get the name of the wrapped MCP server. - - Returns: - The name of the underlying MCP server instance. - """ - return self.server.name - - async def connect(self) -> None: - """Connect to the underlying MCP server. - - Delegates the connection operation to the wrapped server instance. - This method can be called directly when not in a workflow context. - """ - await self.server.connect() - - async def cleanup(self) -> None: - """Clean up the underlying MCP server connection. - - Delegates the cleanup operation to the wrapped server instance. - This method can be called directly when not in a workflow context. - """ - await self.server.cleanup() - - async def list_tools( - self, - run_context: Optional[RunContextWrapper[Any]] = None, - agent: Optional[AgentBase] = None, - ) -> list[MCPTool]: - """List available tools with context-aware execution. - - When called outside a workflow context, delegates directly to the wrapped - server. When called within a workflow, delegates to the parent shim class - which executes the operation via a Temporal activity. - - Args: - run_context: Optional runtime context wrapper for the operation. - agent: Optional agent instance that may be relevant to tool listing. - - Returns: - A list of MCP tools available from the server. - """ - if not workflow.in_workflow(): - return await self.server.list_tools(run_context, agent) - - return await super().list_tools(run_context, agent) - - async def call_tool( - self, tool_name: str, arguments: Optional[dict[str, Any]] - ) -> CallToolResult: - """Execute a tool with context-aware execution. - - When called outside a workflow context, delegates directly to the wrapped - server. When called within a workflow, delegates to the parent shim class - which executes the operation via a Temporal activity. - - Args: - tool_name: The name of the tool to execute. - arguments: Optional dictionary of arguments to pass to the tool. - - Returns: - The result of the tool execution. - """ - if not workflow.in_workflow(): - return await self.server.call_tool(tool_name, arguments) - - return await super().call_tool(tool_name, arguments) - - async def __aenter__(self): - """Async context manager entry - connects to the MCP server. - - Returns: - Self, allowing the server to be used within the context. - """ - await self.connect() - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - """Async context manager exit - cleans up the MCP server connection. - - Args: - exc_type: Exception type, if any exception occurred. - exc_value: Exception value, if any exception occurred. - traceback: Exception traceback, if any exception occurred. - """ - await self.cleanup() - def get_activities(self) -> Sequence[Callable]: - """Get Temporal activity functions for workflow-safe MCP operations. - - This method creates and returns Temporal activity functions that delegate - to the wrapped MCP server. These activities should be registered with a - Temporal worker to enable workflow-safe execution of MCP operations. - - The returned activities include: - - {name}-list-tools: Lists available tools - - {name}-call-tool: Executes a specific tool - - {name}-list-prompts: Lists available prompts - - {name}-get-prompt: Retrieves a specific prompt - - Returns: - A sequence of activity functions to register with a Temporal worker. - - Example: - ```python - # Manual management (not recommended for most cases) - async with TemporalMCPServer(my_mcp_server) as server: - async with Worker( - client, - activities=server.get_activities(), - ) as worker: - ... - - # Recommended: Use OpenAIAgentsPlugin instead - plugin = OpenAIAgentsPlugin(mcp_servers=[my_mcp_server]) - client = await Client.connect("localhost:7233", plugins=[plugin]) - async with Worker(client, task_queue="my-queue", workflows=[MyWorkflow]): - ... - ``` - """ + if self.server is None: + raise ValueError("A full MCPServer implementation should have been provided when adding a server to the worker.") @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: activity.logger.info("Listing tools in activity") - return await self.server.list_tools() + async with self.server: + return await self.server.list_tools() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - return await self.server.call_tool(tool_name, arguments) + async with self.server: + return await self.server.call_tool(tool_name, arguments) @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: - return await self.server.list_prompts() + async with self.server: + return await self.server.list_prompts() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: - return await self.server.get_prompt(name, arguments) + async with self.server: + return await self.server.get_prompt(name, arguments) return list_tools, call_tool, list_prompts, get_prompt - - async def list_prompts(self) -> ListPromptsResult: - """List available prompts with context-aware execution. - - When called outside a workflow context, delegates directly to the wrapped - server. When called within a workflow, delegates to the parent shim class - which executes the operation via a Temporal activity. - - Returns: - A list of available prompts from the server. - """ - if not workflow.in_workflow(): - return await self.server.list_prompts() - - return await super().list_prompts() - - async def get_prompt( - self, name: str, arguments: Optional[dict[str, Any]] = None - ) -> GetPromptResult: - """Get a specific prompt with context-aware execution. - - When called outside a workflow context, delegates directly to the wrapped - server. When called within a workflow, delegates to the parent shim class - which executes the operation via a Temporal activity. - - Args: - name: The name of the prompt to retrieve. - arguments: Optional arguments for the prompt. - - Returns: - The requested prompt from the server. - """ - if not workflow.in_workflow(): - return await self.server.get_prompt(name, arguments) - - return await super().get_prompt(name, arguments) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index e05464a9a..5d3a39bf2 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -52,10 +52,9 @@ ) from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner -if typing.TYPE_CHECKING: - from agents.mcp import MCPServer +from agents.mcp import MCPServer - from temporalio.contrib.openai_agents._mcp import TemporalMCPServer +from temporalio.contrib.openai_agents._mcp import StatelessTemporalMCPServer @contextmanager @@ -166,6 +165,18 @@ def __init__(self) -> None: super().__init__(ToJsonOptions(exclude_unset=True)) +def _transform_mcp_servers(mcp_servers: Sequence[MCPServer]) -> list[MCPServer]: + def _transform_mcp_server(server: MCPServer) -> MCPServer: + if isinstance(server, StatelessTemporalMCPServer): + return server + else: + raise TypeError(f"Unsupported mcp server type {type(server)}") + return [ + _transform_mcp_server(server) + for server in mcp_servers + ] + + class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): """Temporal plugin for integrating OpenAI agents with Temporal workflows. @@ -270,15 +281,7 @@ def __init__( self._model_provider = model_provider if mcp_servers: - # Delayed import as mcp servers only work on python >=3.10 - from temporalio.contrib.openai_agents._mcp import TemporalMCPServer - - self._mcp_servers = [ - server - if isinstance(server, TemporalMCPServer) - else TemporalMCPServer(server) - for server in mcp_servers - ] + self._mcp_servers = _transform_mcp_servers(mcp_servers) else: self._mcp_servers = [] @@ -355,10 +358,7 @@ async def run_worker(self, worker: Worker) -> None: worker: The worker instance to run. """ with set_open_ai_agent_temporal_overrides(self._model_params): - async with AsyncExitStack() as stack: - for mcp_server in self._mcp_servers: - await stack.enter_async_context(mcp_server) - await self.next_worker_plugin.run_worker(worker) + await self.next_worker_plugin.run_worker(worker) def configure_replayer(self, config: ReplayerConfig) -> ReplayerConfig: """Configure the replayer for OpenAI Agents.""" diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 01a46617c..081a4c31b 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -89,6 +89,7 @@ TestModel, TestModelProvider, ) +from temporalio.contrib.openai_agents._mcp import StatelessTemporalMCPServer from temporalio.contrib.openai_agents._temporal_model_stub import _extract_summary from temporalio.contrib.pydantic import pydantic_data_converter from temporalio.exceptions import CancelledError @@ -2512,9 +2513,7 @@ class McpServerWorkflow: async def run(self, question: str) -> str: from agents.mcp import MCPServer - from temporalio.contrib.openai_agents import TemporalMCPServerWorkflowShim - - server: MCPServer = TemporalMCPServerWorkflowShim("Filesystem Server, via npx") + server: MCPServer = StatelessTemporalMCPServer("Filesystem-Server") agent = Agent[str]( name="MCP ServerWorkflow", instructions="Use the tools to read the filesystem and answer questions based on those files.", @@ -2586,7 +2585,7 @@ class McpServerModel(StaticTestModel): @pytest.mark.parametrize("use_local_model", [True, False]) -async def test_mcp_server(client: Client, use_local_model: bool): +async def test_stateless_mcp_server(client: Client, use_local_model: bool): if not use_local_model and not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") @@ -2594,8 +2593,8 @@ async def test_mcp_server(client: Client, use_local_model: bool): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServer, MCPServerStdio - server = MCPServerStdio( - name="Filesystem Server, via npx", + server = StatelessTemporalMCPServer(MCPServerStdio( + name="Filesystem-Server", params={ "command": "npx", "args": [ @@ -2604,18 +2603,8 @@ async def test_mcp_server(client: Client, use_local_model: bool): os.path.dirname(os.path.abspath(__file__)), ], }, - ) - server2 = MCPServerStdio( - name="Some other server", - params={ - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - os.path.dirname(os.path.abspath(__file__)), - ], - }, - ) + )) + new_config = client.config() new_config["plugins"] = [ openai_agents.OpenAIAgentsPlugin( @@ -2625,7 +2614,7 @@ async def test_mcp_server(client: Client, use_local_model: bool): model_provider=TestModelProvider(McpServerModel()) if use_local_model else None, - mcp_servers=[server, server2], + mcp_servers=[server], ) ] client = Client(**new_config) @@ -2644,3 +2633,4 @@ async def test_mcp_server(client: Client, use_local_model: bool): result = await workflow_handle.result() if use_local_model: assert result == "Here are the files and directories in the allowed path." + assert False \ No newline at end of file From 0fd788676782f56784751194621b41f3acb8ea11 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 11 Aug 2025 13:03:23 -0700 Subject: [PATCH 10/39] Include stateful option --- temporalio/contrib/openai_agents/__init__.py | 8 +- temporalio/contrib/openai_agents/_mcp.py | 221 ++++++++++++++++-- .../openai_agents/_temporal_openai_agents.py | 49 ++-- tests/contrib/openai_agents/test_openai.py | 175 ++++++++++++-- 4 files changed, 399 insertions(+), 54 deletions(-) diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index 17c3af092..facb877d3 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -11,8 +11,8 @@ # Best Effort mcp, as it is not supported on Python 3.9 try: from temporalio.contrib.openai_agents._mcp import ( - TemporalMCPServer, - TemporalMCPServerWorkflowShim, + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, ) except ImportError: pass @@ -33,8 +33,8 @@ "OpenAIAgentsPlugin", "ModelActivityParameters", "workflow", - "TemporalMCPServer", - "TemporalMCPServerWorkflowShim", + "StatelessTemporalMCPServer", + "StatefulTemporalMCPServer", "TestModel", "TestModelProvider", ] diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 46fcef096..8a80540de 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -1,3 +1,6 @@ +import asyncio +import logging +import uuid from datetime import timedelta from typing import Any, Callable, Optional, Sequence, Union @@ -8,13 +11,26 @@ from mcp.types import CallToolResult # type:ignore from temporalio import activity, workflow -from temporalio.workflow import ActivityConfig +from temporalio.api.enums.v1.workflow_pb2 import ( + TIMEOUT_TYPE_HEARTBEAT, + TIMEOUT_TYPE_SCHEDULE_TO_START, +) +from temporalio.exceptions import ActivityError, ApplicationError +from temporalio.worker import PollerBehaviorSimpleMaximum, Worker +from temporalio.workflow import ActivityConfig, ActivityHandle + +logger = logging.getLogger(__name__) + class StatelessTemporalMCPServer(MCPServer): - def __init__(self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None): + def __init__( + self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None + ): self.server = server if isinstance(server, MCPServer) else None self._name = (server if isinstance(server, str) else server.name) + "-stateless" - self.config = config or ActivityConfig(start_to_close_timeout=timedelta(minutes=1)) + self.config = config or ActivityConfig( + start_to_close_timeout=timedelta(minutes=1) + ) super().__init__() @property @@ -68,32 +84,209 @@ async def get_prompt( ) def get_activities(self) -> Sequence[Callable]: - if self.server is None: - raise ValueError("A full MCPServer implementation should have been provided when adding a server to the worker.") + server = self.server + if server is None: + raise ValueError( + "A full MCPServer implementation should have been provided when adding a server to the worker." + ) @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: - activity.logger.info("Listing tools in activity") - async with self.server: - return await self.server.list_tools() + try: + await server.connect() + return await server.list_tools() + finally: + await server.cleanup() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - async with self.server: - return await self.server.call_tool(tool_name, arguments) + try: + await server.connect() + return await server.call_tool(tool_name, arguments) + finally: + await server.cleanup() @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: - async with self.server: - return await self.server.list_prompts() + try: + await server.connect() + return await server.list_prompts() + finally: + await server.cleanup() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: - async with self.server: - return await self.server.get_prompt(name, arguments) + try: + await server.connect() + return await server.get_prompt(name, arguments) + finally: + await server.cleanup() return list_tools, call_tool, list_prompts, get_prompt + + +class StatefulTemporalMCPServer(MCPServer): + def __init__( + self, + server: Union[MCPServer, str], + config: Optional[ActivityConfig] = None, + connect_config: Optional[ActivityConfig] = None, + ): + self.server = server if isinstance(server, MCPServer) else None + self._name = (server if isinstance(server, str) else server.name) + "-stateful" + self.config = config or ActivityConfig( + start_to_close_timeout=timedelta(minutes=1), + schedule_to_start_timeout=timedelta(seconds=30), + ) + self.connect_config = connect_config or ActivityConfig( + start_to_close_timeout=timedelta(hours=1), + ) + self._connect_handle: Optional[ActivityHandle] = None + super().__init__() + + @property + def name(self) -> str: + return self._name + + async def connect(self) -> None: + self.config["task_queue"] = workflow.info().workflow_id + "-" + self.name + self._connect_handle = workflow.start_activity( + self.name + "-connect", + args=[], + **self.connect_config, + ) + + async def cleanup(self) -> None: + if self._connect_handle: + self._connect_handle.cancel() + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.cleanup() + + async def list_tools( + self, + run_context: Optional[RunContextWrapper[Any]] = None, + agent: Optional[AgentBase] = None, + ) -> list[MCPTool]: + try: + logger.info("Executing list-tools: %s", self.config) + return await workflow.execute_activity( + self.name + "-list-tools", + args=[], + result_type=list[MCPTool], + **self.config, + ) + except ActivityError as e: + failure = e.failure + if failure: + cause = failure.cause + if cause: + if ( + cause.timeout_failure_info.timeout_type + == TIMEOUT_TYPE_SCHEDULE_TO_START + ): + raise ApplicationError( + "MCP Stateful Server Worker failed to schedule activity." + ) from e + if ( + cause.timeout_failure_info.timeout_type + == TIMEOUT_TYPE_HEARTBEAT + ): + raise ApplicationError( + "MCP Stateful Server Worker failed to heartbeat." + ) from e + raise e + + async def call_tool( + self, tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + return await workflow.execute_activity( + self.name + "-call-tool", + args=[tool_name, arguments], + result_type=CallToolResult, + **self.config, + ) + + async def list_prompts(self) -> ListPromptsResult: + return await workflow.execute_activity( + self.name + "-list-prompts", + args=[], + result_type=ListPromptsResult, + **self.config, + ) + + async def get_prompt( + self, name: str, arguments: Optional[dict[str, Any]] = None + ) -> GetPromptResult: + return await workflow.execute_activity( + self.name + "-get-prompt", + args=[name, arguments], + result_type=GetPromptResult, + **self.config, + ) + + def get_activities(self) -> Sequence[Callable]: + server = self.server + if server is None: + raise ValueError( + "A full MCPServer implementation should have been provided when adding a server to the worker." + ) + + @activity.defn(name=self.name + "-list-tools") + async def list_tools() -> list[MCPTool]: + return await server.list_tools() + + @activity.defn(name=self.name + "-call-tool") + async def call_tool( + tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + return await server.call_tool(tool_name, arguments) + + @activity.defn(name=self.name + "-list-prompts") + async def list_prompts() -> ListPromptsResult: + return await server.list_prompts() + + @activity.defn(name=self.name + "-get-prompt") + async def get_prompt( + name: str, arguments: Optional[dict[str, Any]] + ) -> GetPromptResult: + return await server.get_prompt(name, arguments) + + async def heartbeat_every(delay: float, *details: Any) -> None: + """Heartbeat every so often while not cancelled""" + while True: + await asyncio.sleep(delay) + activity.heartbeat(*details) + + @activity.defn(name=self.name + "-connect") + async def connect() -> None: + logger.info("Connect activity") + heartbeat_task = asyncio.create_task(heartbeat_every(30)) + try: + await server.connect() + + worker = Worker( + activity.client(), + task_queue=activity.info().workflow_id + "-" + self.name, + activities=[list_tools, call_tool, list_prompts, get_prompt], + activity_task_poller_behavior=PollerBehaviorSimpleMaximum(1), + ) + + await worker.run() + finally: + await server.cleanup() + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + + return (connect,) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 5d3a39bf2..5d320a45d 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,8 +1,8 @@ """Initialize Temporal OpenAI Agents overrides.""" import dataclasses -import typing -from contextlib import AsyncExitStack, asynccontextmanager, contextmanager +import warnings +from contextlib import asynccontextmanager, contextmanager from datetime import timedelta from typing import AsyncIterator, Callable, Optional, Sequence, Union @@ -28,6 +28,7 @@ import temporalio.worker from temporalio.client import ClientConfig from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity + from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -52,10 +53,15 @@ ) from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner -from agents.mcp import MCPServer - -from temporalio.contrib.openai_agents._mcp import StatelessTemporalMCPServer - +# Unsupported on python 3.9 +try: + from agents.mcp import MCPServer + from temporalio.contrib.openai_agents._mcp import ( + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, + ) +except ImportError: + pass @contextmanager def set_open_ai_agent_temporal_overrides( @@ -165,18 +171,6 @@ def __init__(self) -> None: super().__init__(ToJsonOptions(exclude_unset=True)) -def _transform_mcp_servers(mcp_servers: Sequence[MCPServer]) -> list[MCPServer]: - def _transform_mcp_server(server: MCPServer) -> MCPServer: - if isinstance(server, StatelessTemporalMCPServer): - return server - else: - raise TypeError(f"Unsupported mcp server type {type(server)}") - return [ - _transform_mcp_server(server) - for server in mcp_servers - ] - - class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): """Temporal plugin for integrating OpenAI agents with Temporal workflows. @@ -281,7 +275,18 @@ def __init__( self._model_provider = model_provider if mcp_servers: - self._mcp_servers = _transform_mcp_servers(mcp_servers) + def _transform_mcp_server(server: "MCPServer") -> "MCPServer": + if not ( + isinstance(server, StatelessTemporalMCPServer) + or isinstance(server, StatefulTemporalMCPServer) + ): + warnings.warn( + f"Unsupported mcp server type {type(server)} is not guaranteed to behave reasonably." + ) + + return server + + self._mcp_servers = [_transform_mcp_server(server) for server in mcp_servers] else: self._mcp_servers = [] @@ -335,7 +340,11 @@ def configure_worker(self, config: WorkerConfig) -> WorkerConfig: ] new_activities = [ModelActivity(self._model_provider).invoke_model_activity] for mcp_server in self._mcp_servers: - new_activities.extend(mcp_server.get_activities()) + if hasattr(mcp_server, "get_activities"): + get_activities: Callable[[], Sequence[Callable]] = getattr( + mcp_server, "get_activities" + ) + new_activities.extend(get_activities()) config["activities"] = list(config.get("activities") or []) + new_activities runner = config.get("workflow_runner") diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 081a4c31b..e039bd0bc 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -8,7 +8,9 @@ from typing import ( Any, AsyncIterator, + Callable, Optional, + Sequence, Union, no_type_check, ) @@ -89,11 +91,11 @@ TestModel, TestModelProvider, ) -from temporalio.contrib.openai_agents._mcp import StatelessTemporalMCPServer from temporalio.contrib.openai_agents._temporal_model_stub import _extract_summary from temporalio.contrib.pydantic import pydantic_data_converter -from temporalio.exceptions import CancelledError +from temporalio.exceptions import ApplicationError, CancelledError from temporalio.testing import WorkflowEnvironment +from temporalio.workflow import ActivityConfig from tests.contrib.openai_agents.research_agents.research_manager import ( ResearchManager, ) @@ -2591,19 +2593,22 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") - from agents.mcp import MCPServer, MCPServerStdio - - server = StatelessTemporalMCPServer(MCPServerStdio( - name="Filesystem-Server", - params={ - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - os.path.dirname(os.path.abspath(__file__)), - ], - }, - )) + from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatelessTemporalMCPServer + + server = StatelessTemporalMCPServer( + MCPServerStdio( + name="Filesystem-Server", + params={ + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__)), + ], + }, + ) + ) new_config = client.config() new_config["plugins"] = [ @@ -2633,4 +2638,142 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): result = await workflow_handle.result() if use_local_model: assert result == "Here are the files and directories in the allowed path." - assert False \ No newline at end of file + assert False + + +@workflow.defn +class McpServerStatefulWorkflow: + @workflow.run + async def run(self, question: str) -> str: + from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + async with StatefulTemporalMCPServer( + "Filesystem-Server", + config=ActivityConfig( + schedule_to_start_timeout=timedelta(seconds=1), + start_to_close_timeout=timedelta(seconds=30), + ), + ) as server: + agent = Agent[str]( + name="MCP ServerWorkflow", + instructions="Use the tools to read the filesystem and answer questions based on those files.", + mcp_servers=[server], + ) + result = await Runner.run(starting_agent=agent, input=question) + return result.final_output + + +@pytest.mark.parametrize("use_local_model", [True, False]) +async def test_stateful_mcp_server(client: Client, use_local_model: bool): + if not use_local_model and not os.environ.get("OPENAI_API_KEY"): + pytest.skip("No openai API key") + + if sys.version_info < (3, 10): + pytest.skip("Mcp not supported on Python 3.9") + from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + + server = StatefulTemporalMCPServer( + MCPServerStdio( + name="Filesystem-Server", + params={ + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__)), + ], + }, + ) + ) + + new_config = client.config() + new_config["plugins"] = [ + openai_agents.OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=120) + ), + model_provider=TestModelProvider(McpServerModel()) + if use_local_model + else None, + mcp_servers=[server], + ) + ] + client = Client(**new_config) + + async with new_worker( + client, + McpServerStatefulWorkflow, + ) as worker: + workflow_handle = await client.start_workflow( + McpServerStatefulWorkflow.run, + "Read the files and list them.", + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + result = await workflow_handle.result() + if use_local_model: + assert result == "Here are the files and directories in the allowed path." + + +async def test_stateful_mcp_server_no_worker(client: Client): + if sys.version_info < (3, 10): + pytest.skip("Mcp not supported on Python 3.9") + from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + + server = StatefulTemporalMCPServer( + MCPServerStdio( + name="Filesystem-Server", + params={ + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + os.path.dirname(os.path.abspath(__file__)), + ], + }, + ) + ) + + # Override the connect activity to not actually start a worker + @activity.defn(name="Filesystem-Server-stateful-connect") + async def connect() -> None: + print("Override connect") + await asyncio.sleep(30) + + def override_get_activities() -> Sequence[Callable]: + return (connect,) + + server.get_activities = override_get_activities # type:ignore + + new_config = client.config() + new_config["plugins"] = [ + openai_agents.OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=120) + ), + model_provider=TestModelProvider(McpServerModel()), + mcp_servers=[server], + ) + ] + client = Client(**new_config) + + async with new_worker( + client, + McpServerStatefulWorkflow, + ) as worker: + workflow_handle = await client.start_workflow( + McpServerStatefulWorkflow.run, + "Read the files and list them.", + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + with pytest.raises(WorkflowFailureError) as err: + await workflow_handle.result() + assert isinstance(err.value.cause, ApplicationError) + assert ( + err.value.cause.message + == "MCP Stateful Server Worker failed to schedule activity." + ) From f3acf3f3d48c3cc75efc46e0b106ae055dc5c476 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 11 Aug 2025 13:10:27 -0700 Subject: [PATCH 11/39] Add docstrings --- temporalio/contrib/openai_agents/_mcp.py | 220 ++++++++++++++++++ .../openai_agents/_temporal_openai_agents.py | 19 +- tests/contrib/openai_agents/test_openai.py | 8 +- 3 files changed, 238 insertions(+), 9 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 8a80540de..3a65e0d39 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -23,9 +23,26 @@ class StatelessTemporalMCPServer(MCPServer): + """A stateless MCP server implementation for Temporal workflows. + + This class wraps an MCP server to make it stateless by executing each MCP operation + as a separate Temporal activity. Each operation (list_tools, call_tool, etc.) will + connect to the underlying server, execute the operation, and then clean up the connection. + + This approach is suitable for simple use cases where connection overhead is acceptable + and you don't need to maintain state between operations. + """ + def __init__( self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None ): + """Initialize the stateless temporal MCP server. + + Args: + server: Either an MCPServer instance or a string name for the server. + config: Optional activity configuration for Temporal activities. Defaults to + 1-minute start-to-close timeout if not provided. + """ self.server = server if isinstance(server, MCPServer) else None self._name = (server if isinstance(server, str) else server.name) + "-stateless" self.config = config or ActivityConfig( @@ -35,12 +52,27 @@ def __init__( @property def name(self) -> str: + """Get the server name with '-stateless' suffix. + + Returns: + The server name with '-stateless' appended. + """ return self._name async def connect(self) -> None: + """Connect to the MCP server. + + For stateless servers, this is a no-op since connections are made + on a per-operation basis. + """ pass async def cleanup(self) -> None: + """Clean up the MCP server connection. + + For stateless servers, this is a no-op since connections are cleaned + up after each operation. + """ pass async def list_tools( @@ -48,6 +80,21 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: + """List available tools from the MCP server. + + This method executes a Temporal activity to connect to the MCP server, + retrieve the list of available tools, and clean up the connection. + + Args: + run_context: Optional run context wrapper (unused in stateless mode). + agent: Optional agent base (unused in stateless mode). + + Returns: + A list of available MCP tools. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-list-tools", args=[], @@ -58,6 +105,21 @@ async def list_tools( async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: + """Call a specific tool on the MCP server. + + This method executes a Temporal activity to connect to the MCP server, + call the specified tool with the given arguments, and clean up the connection. + + Args: + tool_name: The name of the tool to call. + arguments: Optional dictionary of arguments to pass to the tool. + + Returns: + The result of the tool call. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -66,6 +128,17 @@ async def call_tool( ) async def list_prompts(self) -> ListPromptsResult: + """List available prompts from the MCP server. + + This method executes a Temporal activity to connect to the MCP server, + retrieve the list of available prompts, and clean up the connection. + + Returns: + A list of available prompts. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -76,6 +149,21 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: + """Get a specific prompt from the MCP server. + + This method executes a Temporal activity to connect to the MCP server, + retrieve the specified prompt with optional arguments, and clean up the connection. + + Args: + name: The name of the prompt to retrieve. + arguments: Optional dictionary of arguments for the prompt. + + Returns: + The prompt result. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], @@ -84,6 +172,17 @@ async def get_prompt( ) def get_activities(self) -> Sequence[Callable]: + """Get the Temporal activities for this MCP server. + + Creates and returns the Temporal activity functions that handle MCP operations. + Each activity manages its own connection lifecycle (connect -> operate -> cleanup). + + Returns: + A sequence of Temporal activity functions. + + Raises: + ValueError: If no MCP server instance was provided during initialization. + """ server = self.server if server is None: raise ValueError( @@ -130,12 +229,35 @@ async def get_prompt( class StatefulTemporalMCPServer(MCPServer): + """A stateful MCP server implementation for Temporal workflows. + + This class wraps an MCP server to maintain a persistent connection throughout + the workflow execution. It creates a dedicated worker that stays connected to + the MCP server and processes operations on a dedicated task queue. + + This approach is more efficient for workflows that make multiple MCP calls, + as it avoids connection overhead, but requires more resources to maintain + the persistent connection and worker. + + The caller will have to handle cases where the dedicated worker fails, as Temporal is + unable to seamlessly recreate any lost state in that case. + """ + def __init__( self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None, connect_config: Optional[ActivityConfig] = None, ): + """Initialize the stateful temporal MCP server. + + Args: + server: Either an MCPServer instance or a string name for the server. + config: Optional activity configuration for MCP operation activities. + Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. + connect_config: Optional activity configuration for the connection activity. + Defaults to 1-hour start-to-close timeout. + """ self.server = server if isinstance(server, MCPServer) else None self._name = (server if isinstance(server, str) else server.name) + "-stateful" self.config = config or ActivityConfig( @@ -150,9 +272,20 @@ def __init__( @property def name(self) -> str: + """Get the server name with '-stateful' suffix. + + Returns: + The server name with '-stateful' appended. + """ return self._name async def connect(self) -> None: + """Connect to the MCP server and start the dedicated worker. + + This method creates a dedicated task queue for this workflow and starts + a long-running activity that maintains the connection and runs a worker + to handle MCP operations. + """ self.config["task_queue"] = workflow.info().workflow_id + "-" + self.name self._connect_handle = workflow.start_activity( self.name + "-connect", @@ -161,14 +294,32 @@ async def connect(self) -> None: ) async def cleanup(self) -> None: + """Clean up the MCP server connection. + + This method cancels the long-running connection activity, which will + cause the dedicated worker to shut down and the MCP server connection + to be closed. + """ if self._connect_handle: self._connect_handle.cancel() async def __aenter__(self): + """Async context manager entry point. + + Returns: + This server instance after connecting. + """ await self.connect() return self async def __aexit__(self, exc_type, exc_value, traceback): + """Async context manager exit point. + + Args: + exc_type: Exception type if an exception occurred. + exc_value: Exception value if an exception occurred. + traceback: Exception traceback if an exception occurred. + """ await self.cleanup() async def list_tools( @@ -176,6 +327,22 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: + """List available tools from the MCP server. + + This method executes a Temporal activity on the dedicated task queue + to retrieve the list of available tools from the persistent MCP connection. + + Args: + run_context: Optional run context wrapper (unused in stateful mode). + agent: Optional agent base (unused in stateful mode). + + Returns: + A list of available MCP tools. + + Raises: + ApplicationError: If the MCP worker fails to schedule or heartbeat. + ActivityError: If the underlying Temporal activity fails. + """ try: logger.info("Executing list-tools: %s", self.config) return await workflow.execute_activity( @@ -208,6 +375,21 @@ async def list_tools( async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: + """Call a specific tool on the MCP server. + + This method executes a Temporal activity on the dedicated task queue + to call the specified tool using the persistent MCP connection. + + Args: + tool_name: The name of the tool to call. + arguments: Optional dictionary of arguments to pass to the tool. + + Returns: + The result of the tool call. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -216,6 +398,17 @@ async def call_tool( ) async def list_prompts(self) -> ListPromptsResult: + """List available prompts from the MCP server. + + This method executes a Temporal activity on the dedicated task queue + to retrieve the list of available prompts from the persistent MCP connection. + + Returns: + A list of available prompts. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -226,6 +419,21 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: + """Get a specific prompt from the MCP server. + + This method executes a Temporal activity on the dedicated task queue + to retrieve the specified prompt using the persistent MCP connection. + + Args: + name: The name of the prompt to retrieve. + arguments: Optional dictionary of arguments for the prompt. + + Returns: + The prompt result. + + Raises: + ActivityError: If the underlying Temporal activity fails. + """ return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], @@ -234,6 +442,18 @@ async def get_prompt( ) def get_activities(self) -> Sequence[Callable]: + """Get the Temporal activities for this stateful MCP server. + + Creates and returns the Temporal activity functions that handle MCP operations + and connection management. This includes a long-running connect activity that + maintains the MCP connection and runs a dedicated worker. + + Returns: + A sequence containing the connect activity function. + + Raises: + ValueError: If no MCP server instance was provided during initialization. + """ server = self.server if server is None: raise ValueError( diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 5d320a45d..de846b0a3 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -28,7 +28,6 @@ import temporalio.worker from temporalio.client import ClientConfig from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity - from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -56,13 +55,10 @@ # Unsupported on python 3.9 try: from agents.mcp import MCPServer - from temporalio.contrib.openai_agents._mcp import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, - ) except ImportError: pass + @contextmanager def set_open_ai_agent_temporal_overrides( model_params: ModelActivityParameters, @@ -275,10 +271,15 @@ def __init__( self._model_provider = model_provider if mcp_servers: + from temporalio.contrib.openai_agents._mcp import ( + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, + ) + def _transform_mcp_server(server: "MCPServer") -> "MCPServer": if not ( - isinstance(server, StatelessTemporalMCPServer) - or isinstance(server, StatefulTemporalMCPServer) + isinstance(server, StatelessTemporalMCPServer) + or isinstance(server, StatefulTemporalMCPServer) ): warnings.warn( f"Unsupported mcp server type {type(server)} is not guaranteed to behave reasonably." @@ -286,7 +287,9 @@ def _transform_mcp_server(server: "MCPServer") -> "MCPServer": return server - self._mcp_servers = [_transform_mcp_server(server) for server in mcp_servers] + self._mcp_servers = [ + _transform_mcp_server(server) for server in mcp_servers + ] else: self._mcp_servers = [] diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index e039bd0bc..65568bfd1 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2515,6 +2515,8 @@ class McpServerWorkflow: async def run(self, question: str) -> str: from agents.mcp import MCPServer + from temporalio.contrib.openai_agents import StatelessTemporalMCPServer + server: MCPServer = StatelessTemporalMCPServer("Filesystem-Server") agent = Agent[str]( name="MCP ServerWorkflow", @@ -2594,8 +2596,9 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatelessTemporalMCPServer - + server = StatelessTemporalMCPServer( MCPServerStdio( name="Filesystem-Server", @@ -2646,6 +2649,7 @@ class McpServerStatefulWorkflow: @workflow.run async def run(self, question: str) -> str: from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + async with StatefulTemporalMCPServer( "Filesystem-Server", config=ActivityConfig( @@ -2670,6 +2674,7 @@ async def test_stateful_mcp_server(client: Client, use_local_model: bool): if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatefulTemporalMCPServer server = StatefulTemporalMCPServer( @@ -2720,6 +2725,7 @@ async def test_stateful_mcp_server_no_worker(client: Client): if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServerStdio + from temporalio.contrib.openai_agents import StatefulTemporalMCPServer server = StatefulTemporalMCPServer( From c7a78a1bfb76c3d4547b536658363700ddf24a0b Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 12 Aug 2025 08:54:59 -0700 Subject: [PATCH 12/39] Remove merge duplicate --- tests/contrib/openai_agents/test_openai.py | 41 ---------------------- 1 file changed, 41 deletions(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 89dc93143..b1eff1d73 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2068,47 +2068,6 @@ async def run(self, question: str) -> str: return result.final_output -class ResponseBuilders: - @staticmethod - def model_response(output: TResponseOutputItem) -> ModelResponse: - return ModelResponse( - output=[output], - usage=Usage(), - response_id=None, - ) - - @staticmethod - def tool_call(arguments: str, name: str) -> ModelResponse: - return ResponseBuilders.model_response( - ResponseFunctionToolCall( - arguments=arguments, - call_id="call", - name=name, - type="function_call", - id="id", - status="completed", - ) - ) - - @staticmethod - def output_message(text: str) -> ModelResponse: - return ResponseBuilders.model_response( - ResponseOutputMessage( - id="", - content=[ - ResponseOutputText( - text=text, - annotations=[], - type="output_text", - ) - ], - role="assistant", - status="completed", - type="message", - ) - ) - - class McpServerModel(StaticTestModel): responses = [ ResponseBuilders.tool_call( From 0475d0ce5d6b0281ef650c4f76a064b0db83bfea Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 12 Aug 2025 09:13:15 -0700 Subject: [PATCH 13/39] Some cleanup --- .../contrib/openai_agents/_openai_runner.py | 10 ++++--- .../openai_agents/_temporal_openai_agents.py | 29 +++---------------- tests/contrib/openai_agents/test_openai.py | 1 - 3 files changed, 10 insertions(+), 30 deletions(-) diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index d279f080c..d306a14e8 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -1,5 +1,6 @@ import json import typing +import warnings from dataclasses import replace from typing import Any, Union @@ -54,10 +55,11 @@ async def run( "Provided tool is not a tool type. If using an activity, make sure to wrap it with openai_agents.workflow.activity_as_tool." ) - # if starting_agent.mcp_servers: - # raise ValueError( - # "Temporal OpenAI agent does not support on demand MCP servers." - # ) + if starting_agent.mcp_servers: + from temporalio.contrib.openai_agents import (StatelessTemporalMCPServer, StatefulTemporalMCPServer) + for s in starting_agent.mcp_servers: + if not isinstance(s, (StatelessTemporalMCPServer, StatefulTemporalMCPServer)): + warnings.warn("Unknown mcp_server type {} may not work durably.".format(type(s))) # workaround for https://github.com/pydantic/pydantic/issues/9541 # ValidatorIterator returned diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 5d9de7b4d..7fdc3f9f6 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -195,7 +195,7 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): Example: >>> from temporalio.client import Client >>> from temporalio.worker import Worker - >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters + >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessTemporalMCPServer >>> from agents.mcp import MCPServerStdio >>> from datetime import timedelta >>> @@ -206,10 +206,10 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): ... ) >>> >>> # Create MCP servers - >>> filesystem_server = MCPServerStdio( + >>> filesystem_server = StatelessTemporalMCPServer(MCPServerStdio( ... name="Filesystem Server", ... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]} - ... ) + ... )) >>> >>> # Create plugin with MCP servers >>> plugin = OpenAIAgentsPlugin( @@ -265,29 +265,8 @@ def __init__( self._model_params = model_params self._model_provider = model_provider + self._mcp_servers = mcp_servers - if mcp_servers: - from temporalio.contrib.openai_agents._mcp import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, - ) - - def _transform_mcp_server(server: "MCPServer") -> "MCPServer": - if not ( - isinstance(server, StatelessTemporalMCPServer) - or isinstance(server, StatefulTemporalMCPServer) - ): - warnings.warn( - f"Unsupported mcp server type {type(server)} is not guaranteed to behave reasonably." - ) - - return server - - self._mcp_servers = [ - _transform_mcp_server(server) for server in mcp_servers - ] - else: - self._mcp_servers = [] def init_client_plugin(self, next: temporalio.client.Plugin) -> None: """Set the next client plugin""" diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index b1eff1d73..12092a338 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2245,7 +2245,6 @@ async def test_stateful_mcp_server_no_worker(client: Client): # Override the connect activity to not actually start a worker @activity.defn(name="Filesystem-Server-stateful-connect") async def connect() -> None: - print("Override connect") await asyncio.sleep(30) def override_get_activities() -> Sequence[Callable]: From eb86407534fe53dee1c81de0d1cb3302dc40f949 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 12 Aug 2025 09:18:39 -0700 Subject: [PATCH 14/39] Lint --- .../contrib/openai_agents/_openai_runner.py | 16 +++++++++++++--- .../openai_agents/_temporal_openai_agents.py | 1 - 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index d306a14e8..a8891fddf 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -56,10 +56,20 @@ async def run( ) if starting_agent.mcp_servers: - from temporalio.contrib.openai_agents import (StatelessTemporalMCPServer, StatefulTemporalMCPServer) + from temporalio.contrib.openai_agents import ( + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, + ) + for s in starting_agent.mcp_servers: - if not isinstance(s, (StatelessTemporalMCPServer, StatefulTemporalMCPServer)): - warnings.warn("Unknown mcp_server type {} may not work durably.".format(type(s))) + if not isinstance( + s, (StatelessTemporalMCPServer, StatefulTemporalMCPServer) + ): + warnings.warn( + "Unknown mcp_server type {} may not work durably.".format( + type(s) + ) + ) # workaround for https://github.com/pydantic/pydantic/issues/9541 # ValidatorIterator returned diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 7fdc3f9f6..8fef4f196 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -267,7 +267,6 @@ def __init__( self._model_provider = model_provider self._mcp_servers = mcp_servers - def init_client_plugin(self, next: temporalio.client.Plugin) -> None: """Set the next client plugin""" self.next_client_plugin = next From ce2eb9ae8f4f78a7aa42f87628dc859fd5440f70 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 12 Aug 2025 12:08:24 -0700 Subject: [PATCH 15/39] Fix up tests --- tests/contrib/openai_agents/test_openai.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 12092a338..f59bcc244 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2141,19 +2141,18 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): result = await workflow_handle.result() if use_local_model: assert result == "Here are the files and directories in the allowed path." - assert False @workflow.defn class McpServerStatefulWorkflow: @workflow.run - async def run(self, question: str) -> str: + async def run(self, timeout: timedelta) -> str: from temporalio.contrib.openai_agents import StatefulTemporalMCPServer async with StatefulTemporalMCPServer( "Filesystem-Server", config=ActivityConfig( - schedule_to_start_timeout=timedelta(seconds=1), + schedule_to_start_timeout=timeout, start_to_close_timeout=timedelta(seconds=30), ), ) as server: @@ -2162,7 +2161,7 @@ async def run(self, question: str) -> str: instructions="Use the tools to read the filesystem and answer questions based on those files.", mcp_servers=[server], ) - result = await Runner.run(starting_agent=agent, input=question) + result = await Runner.run(starting_agent=agent, input="Read the files and list them.") return result.final_output @@ -2211,7 +2210,7 @@ async def test_stateful_mcp_server(client: Client, use_local_model: bool): ) as worker: workflow_handle = await client.start_workflow( McpServerStatefulWorkflow.run, - "Read the files and list them.", + timedelta(seconds=30), id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), @@ -2270,7 +2269,7 @@ def override_get_activities() -> Sequence[Callable]: ) as worker: workflow_handle = await client.start_workflow( McpServerStatefulWorkflow.run, - "Read the files and list them.", + timedelta(seconds=1), id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), From c441326242f4d37e696f2c60163e76d377c5e62d Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 13 Aug 2025 07:57:51 -0700 Subject: [PATCH 16/39] Lint --- tests/contrib/openai_agents/test_openai.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index f59bcc244..73be37711 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2161,7 +2161,9 @@ async def run(self, timeout: timedelta) -> str: instructions="Use the tools to read the filesystem and answer questions based on those files.", mcp_servers=[server], ) - result = await Runner.run(starting_agent=agent, input="Read the files and list them.") + result = await Runner.run( + starting_agent=agent, input="Read the files and list them." + ) return result.final_output From 4a4378c8aa2d4fd541f1d7d6777b3dcd187670b6 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 18 Aug 2025 16:42:49 -0700 Subject: [PATCH 17/39] Restructure based on feedback - docstring need updating still --- temporalio/contrib/openai_agents/_mcp.py | 208 +++++++++++------- .../contrib/openai_agents/_openai_runner.py | 12 +- .../openai_agents/_temporal_openai_agents.py | 15 +- temporalio/contrib/openai_agents/workflow.py | 39 +++- tests/contrib/openai_agents/test_openai.py | 15 +- 5 files changed, 190 insertions(+), 99 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 3a65e0d39..bad21f55d 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -1,6 +1,7 @@ +import abc import asyncio +import functools import logging -import uuid from datetime import timedelta from typing import Any, Callable, Optional, Sequence, Union @@ -22,7 +23,15 @@ logger = logging.getLogger(__name__) -class StatelessTemporalMCPServer(MCPServer): +class TemporalMCPServer(abc.ABC): + @property + @abc.abstractmethod + def name(self) -> str: + """Get the server name.""" + raise NotImplementedError() + + +class StatelessTemporalMCPServerReference(MCPServer): """A stateless MCP server implementation for Temporal workflows. This class wraps an MCP server to make it stateless by executing each MCP operation @@ -33,9 +42,7 @@ class StatelessTemporalMCPServer(MCPServer): and you don't need to maintain state between operations. """ - def __init__( - self, server: Union[MCPServer, str], config: Optional[ActivityConfig] = None - ): + def __init__(self, server: str, config: Optional[ActivityConfig] = None): """Initialize the stateless temporal MCP server. Args: @@ -43,9 +50,8 @@ def __init__( config: Optional activity configuration for Temporal activities. Defaults to 1-minute start-to-close timeout if not provided. """ - self.server = server if isinstance(server, MCPServer) else None - self._name = (server if isinstance(server, str) else server.name) + "-stateless" - self.config = config or ActivityConfig( + self._name = server + "-stateless" + self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1) ) super().__init__() @@ -99,7 +105,7 @@ async def list_tools( self.name + "-list-tools", args=[], result_type=list[MCPTool], - **self.config, + **self._config, ) async def call_tool( @@ -124,7 +130,7 @@ async def call_tool( self.name + "-call-tool", args=[tool_name, arguments], result_type=CallToolResult, - **self.config, + **self._config, ) async def list_prompts(self) -> ListPromptsResult: @@ -143,7 +149,7 @@ async def list_prompts(self) -> ListPromptsResult: self.name + "-list-prompts", args=[], result_type=ListPromptsResult, - **self.config, + **self._config, ) async def get_prompt( @@ -168,9 +174,37 @@ async def get_prompt( self.name + "-get-prompt", args=[name, arguments], result_type=GetPromptResult, - **self.config, + **self._config, ) + +class StatelessTemporalMCPServer(TemporalMCPServer): + """A stateless MCP server implementation for Temporal workflows. + + This class wraps an MCP server to make it stateless by executing each MCP operation + as a separate Temporal activity. Each operation (list_tools, call_tool, etc.) will + connect to the underlying server, execute the operation, and then clean up the connection. + + This approach is suitable for simple use cases where connection overhead is acceptable + and you don't need to maintain state between operations. It is encouraged when possible as it provides + a better set of durability guarantees that the stateful version. + """ + + def __init__(self, server: MCPServer): + """Initialize the stateless temporal MCP server. + + Args: + server: An MCPServer instance + """ + self._server = server + self._name = server.name + "-stateless" + super().__init__() + + @property + def name(self) -> str: + """Get the server name.""" + return self._name + def get_activities(self) -> Sequence[Callable]: """Get the Temporal activities for this MCP server. @@ -183,11 +217,7 @@ def get_activities(self) -> Sequence[Callable]: Raises: ValueError: If no MCP server instance was provided during initialization. """ - server = self.server - if server is None: - raise ValueError( - "A full MCPServer implementation should have been provided when adding a server to the worker." - ) + server = self._server @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: @@ -228,43 +258,57 @@ async def get_prompt( return list_tools, call_tool, list_prompts, get_prompt -class StatefulTemporalMCPServer(MCPServer): - """A stateful MCP server implementation for Temporal workflows. - - This class wraps an MCP server to maintain a persistent connection throughout - the workflow execution. It creates a dedicated worker that stays connected to - the MCP server and processes operations on a dedicated task queue. +def _handle_worker_failure(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ActivityError as e: + failure = e.failure + if failure: + cause = failure.cause + if cause: + if ( + cause.timeout_failure_info.timeout_type + == TIMEOUT_TYPE_SCHEDULE_TO_START + ): + raise ApplicationError( + "MCP Stateful Server Worker failed to schedule activity." + ) from e + if ( + cause.timeout_failure_info.timeout_type + == TIMEOUT_TYPE_HEARTBEAT + ): + raise ApplicationError( + "MCP Stateful Server Worker failed to heartbeat." + ) from e + raise e - This approach is more efficient for workflows that make multiple MCP calls, - as it avoids connection overhead, but requires more resources to maintain - the persistent connection and worker. + return wrapper - The caller will have to handle cases where the dedicated worker fails, as Temporal is - unable to seamlessly recreate any lost state in that case. - """ +class StatefulTemporalMCPServerReference(MCPServer): def __init__( self, - server: Union[MCPServer, str], + server: str, config: Optional[ActivityConfig] = None, connect_config: Optional[ActivityConfig] = None, ): """Initialize the stateful temporal MCP server. Args: - server: Either an MCPServer instance or a string name for the server. + server: A string name for the server. Should match that provided in the plugin. config: Optional activity configuration for MCP operation activities. Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. connect_config: Optional activity configuration for the connection activity. Defaults to 1-hour start-to-close timeout. """ - self.server = server if isinstance(server, MCPServer) else None - self._name = (server if isinstance(server, str) else server.name) + "-stateful" - self.config = config or ActivityConfig( + self._name = server + "-stateful" + self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1), schedule_to_start_timeout=timedelta(seconds=30), ) - self.connect_config = connect_config or ActivityConfig( + self._connect_config = connect_config or ActivityConfig( start_to_close_timeout=timedelta(hours=1), ) self._connect_handle: Optional[ActivityHandle] = None @@ -286,11 +330,11 @@ async def connect(self) -> None: a long-running activity that maintains the connection and runs a worker to handle MCP operations. """ - self.config["task_queue"] = workflow.info().workflow_id + "-" + self.name + self._config["task_queue"] = workflow.info().workflow_id + "-" + self.name self._connect_handle = workflow.start_activity( self.name + "-connect", args=[], - **self.connect_config, + **self._connect_config, ) async def cleanup(self) -> None: @@ -322,6 +366,7 @@ async def __aexit__(self, exc_type, exc_value, traceback): """ await self.cleanup() + @_handle_worker_failure async def list_tools( self, run_context: Optional[RunContextWrapper[Any]] = None, @@ -343,35 +388,14 @@ async def list_tools( ApplicationError: If the MCP worker fails to schedule or heartbeat. ActivityError: If the underlying Temporal activity fails. """ - try: - logger.info("Executing list-tools: %s", self.config) - return await workflow.execute_activity( - self.name + "-list-tools", - args=[], - result_type=list[MCPTool], - **self.config, - ) - except ActivityError as e: - failure = e.failure - if failure: - cause = failure.cause - if cause: - if ( - cause.timeout_failure_info.timeout_type - == TIMEOUT_TYPE_SCHEDULE_TO_START - ): - raise ApplicationError( - "MCP Stateful Server Worker failed to schedule activity." - ) from e - if ( - cause.timeout_failure_info.timeout_type - == TIMEOUT_TYPE_HEARTBEAT - ): - raise ApplicationError( - "MCP Stateful Server Worker failed to heartbeat." - ) from e - raise e + return await workflow.execute_activity( + self.name + "-list-tools", + args=[], + result_type=list[MCPTool], + **self._config, + ) + @_handle_worker_failure async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: @@ -394,9 +418,10 @@ async def call_tool( self.name + "-call-tool", args=[tool_name, arguments], result_type=CallToolResult, - **self.config, + **self._config, ) + @_handle_worker_failure async def list_prompts(self) -> ListPromptsResult: """List available prompts from the MCP server. @@ -413,9 +438,10 @@ async def list_prompts(self) -> ListPromptsResult: self.name + "-list-prompts", args=[], result_type=ListPromptsResult, - **self.config, + **self._config, ) + @_handle_worker_failure async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: @@ -438,9 +464,46 @@ async def get_prompt( self.name + "-get-prompt", args=[name, arguments], result_type=GetPromptResult, - **self.config, + **self._config, ) + +class StatefulTemporalMCPServer(TemporalMCPServer): + """A stateful MCP server implementation for Temporal workflows. + + This class wraps an MCP server to maintain a persistent connection throughout + the workflow execution. It creates a dedicated worker that stays connected to + the MCP server and processes operations on a dedicated task queue. + + This approach is more efficient for workflows that make multiple MCP calls, + as it avoids connection overhead, but requires more resources to maintain + the persistent connection and worker. + + The caller will have to handle cases where the dedicated worker fails, as Temporal is + unable to seamlessly recreate any lost state in that case. + """ + + def __init__( + self, + server: MCPServer, + ): + """Initialize the stateful temporal MCP server. + + Args: + server: Either an MCPServer instance or a string name for the server. + connect_config: Optional activity configuration for the connection activity. + Defaults to 1-hour start-to-close timeout. + """ + self._server = server + self._name = self._server.name + "-stateful" + self._connect_handle: Optional[ActivityHandle] = None + super().__init__() + + @property + def name(self) -> str: + """Get the server name.""" + return self._name + def get_activities(self) -> Sequence[Callable]: """Get the Temporal activities for this stateful MCP server. @@ -454,11 +517,7 @@ def get_activities(self) -> Sequence[Callable]: Raises: ValueError: If no MCP server instance was provided during initialization. """ - server = self.server - if server is None: - raise ValueError( - "A full MCPServer implementation should have been provided when adding a server to the worker." - ) + server = self._server @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: @@ -486,9 +545,8 @@ async def heartbeat_every(delay: float, *details: Any) -> None: await asyncio.sleep(delay) activity.heartbeat(*details) - @activity.defn(name=self.name + "-connect") + @activity.defn(name=self._name + "-connect") async def connect() -> None: - logger.info("Connect activity") heartbeat_task = asyncio.create_task(heartbeat_every(30)) try: await server.connect() diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index a8891fddf..2a81c6754 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -56,14 +56,18 @@ async def run( ) if starting_agent.mcp_servers: - from temporalio.contrib.openai_agents import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, + from temporalio.contrib.openai_agents._mcp import ( + StatefulTemporalMCPServerReference, + StatelessTemporalMCPServerReference, ) for s in starting_agent.mcp_servers: if not isinstance( - s, (StatelessTemporalMCPServer, StatefulTemporalMCPServer) + s, + ( + StatelessTemporalMCPServerReference, + StatefulTemporalMCPServerReference, + ), ): warnings.warn( "Unknown mcp_server type {} may not work durably.".format( diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 8fef4f196..08fe9771a 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,7 +1,6 @@ """Initialize Temporal OpenAI Agents overrides.""" import dataclasses -import warnings from contextlib import asynccontextmanager, contextmanager from datetime import timedelta from typing import AsyncIterator, Callable, Optional, Sequence, Union @@ -27,7 +26,12 @@ import temporalio.client import temporalio.worker from temporalio.client import ClientConfig +from temporalio.contrib.openai_agents import ( + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, +) from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity +from temporalio.contrib.openai_agents._mcp import TemporalMCPServer from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -233,7 +237,7 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence["MCPServer"] = (), + mcp_servers: Sequence[TemporalMCPServer] = (), ) -> None: """Initialize the OpenAI agents plugin. @@ -316,6 +320,13 @@ def configure_worker(self, config: WorkerConfig) -> WorkerConfig: OpenAIAgentsTracingInterceptor() ] new_activities = [ModelActivity(self._model_provider).invoke_model_activity] + + server_names = [server.name for server in self._mcp_servers] + if len(server_names) != len(set(server_names)): + raise ValueError( + f"More than one mcp server registered with the same name. Please provide unique names." + ) + for mcp_server in self._mcp_servers: if hasattr(mcp_server, "get_activities"): get_activities: Callable[[], Sequence[Callable]] = getattr( diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index d9f27e679..cf8970cd9 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -3,31 +3,36 @@ import functools import inspect import json +import typing from datetime import timedelta -from typing import Any, Callable, Optional, Type, Union, overload +from typing import Any, Callable, Optional, Type import nexusrpc from agents import ( - Agent, RunContextWrapper, Tool, ) -from agents.function_schema import DocstringStyle, function_schema +from agents.function_schema import function_schema from agents.tool import ( FunctionTool, - ToolErrorFunction, - ToolFunction, - ToolParams, - default_tool_error_function, - function_tool, ) -from agents.util._types import MaybeAwaitable from temporalio import activity from temporalio import workflow as temporal_workflow from temporalio.common import Priority, RetryPolicy +from temporalio.contrib.openai_agents._mcp import ( + StatefulTemporalMCPServerReference, + StatelessTemporalMCPServerReference, +) from temporalio.exceptions import ApplicationError, TemporalError -from temporalio.workflow import ActivityCancellationType, VersioningIntent +from temporalio.workflow import ( + ActivityCancellationType, + ActivityConfig, + VersioningIntent, +) + +if typing.TYPE_CHECKING: + from agents.mcp import MCPServer def activity_as_tool( @@ -239,6 +244,20 @@ async def run_operation(ctx: RunContextWrapper[Any], input: str) -> Any: ) +def create_stateless_mcp_server_reference( + name: str, config: Optional[ActivityConfig] = None +) -> "MCPServer": + return StatelessTemporalMCPServerReference(name, config) + + +def create_stateful_mcp_server_reference( + name: str, + config: Optional[ActivityConfig] = None, + connect_config: Optional[ActivityConfig] = None, +) -> "StatefulTemporalMCPServerReference": + return StatefulTemporalMCPServerReference(name, config, connect_config) + + class ToolSerializationError(TemporalError): """Error that occurs when a tool output could not be serialized. diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 73be37711..e85077cca 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2056,9 +2056,11 @@ class McpServerWorkflow: async def run(self, question: str) -> str: from agents.mcp import MCPServer - from temporalio.contrib.openai_agents import StatelessTemporalMCPServer - - server: MCPServer = StatelessTemporalMCPServer("Filesystem-Server") + server: MCPServer = ( + openai_agents.workflow.create_stateless_mcp_server_reference( + "Filesystem-Server" + ) + ) agent = Agent[str]( name="MCP ServerWorkflow", instructions="Use the tools to read the filesystem and answer questions based on those files.", @@ -2128,8 +2130,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): client = Client(**new_config) async with new_worker( - client, - McpServerWorkflow, + client, McpServerWorkflow, activities=[get_weather, get_weather] ) as worker: workflow_handle = await client.start_workflow( McpServerWorkflow.run, @@ -2147,9 +2148,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): class McpServerStatefulWorkflow: @workflow.run async def run(self, timeout: timedelta) -> str: - from temporalio.contrib.openai_agents import StatefulTemporalMCPServer - - async with StatefulTemporalMCPServer( + async with openai_agents.workflow.create_stateful_mcp_server_reference( "Filesystem-Server", config=ActivityConfig( schedule_to_start_timeout=timeout, From e86e13fd647191b9ded2ec246e8bc49a464fff80 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 18 Aug 2025 17:21:47 -0700 Subject: [PATCH 18/39] Change workflow function names --- temporalio/contrib/openai_agents/workflow.py | 4 ++-- tests/contrib/openai_agents/test_openai.py | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index cf8970cd9..cd99c5c5f 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -244,13 +244,13 @@ async def run_operation(ctx: RunContextWrapper[Any], input: str) -> Any: ) -def create_stateless_mcp_server_reference( +def get_stateless_mcp_server( name: str, config: Optional[ActivityConfig] = None ) -> "MCPServer": return StatelessTemporalMCPServerReference(name, config) -def create_stateful_mcp_server_reference( +def get_stateful_mcp_server( name: str, config: Optional[ActivityConfig] = None, connect_config: Optional[ActivityConfig] = None, diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index e85077cca..9fdeafad8 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2056,10 +2056,8 @@ class McpServerWorkflow: async def run(self, question: str) -> str: from agents.mcp import MCPServer - server: MCPServer = ( - openai_agents.workflow.create_stateless_mcp_server_reference( - "Filesystem-Server" - ) + server: MCPServer = openai_agents.workflow.get_stateless_mcp_server( + "Filesystem-Server" ) agent = Agent[str]( name="MCP ServerWorkflow", @@ -2148,7 +2146,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): class McpServerStatefulWorkflow: @workflow.run async def run(self, timeout: timedelta) -> str: - async with openai_agents.workflow.create_stateful_mcp_server_reference( + async with openai_agents.workflow.get_stateful_mcp_server( "Filesystem-Server", config=ActivityConfig( schedule_to_start_timeout=timeout, From ec8b24184a4a739eaa8e47d0fd4eb870da54a968 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 19 Aug 2025 10:12:36 -0700 Subject: [PATCH 19/39] Cleanup --- temporalio/contrib/openai_agents/_mcp.py | 190 +----------------- .../contrib/openai_agents/_openai_runner.py | 8 +- temporalio/contrib/openai_agents/workflow.py | 52 ++++- tests/contrib/openai_agents/test_openai.py | 7 +- 4 files changed, 58 insertions(+), 199 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index bad21f55d..7b69db1f1 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -2,6 +2,7 @@ import asyncio import functools import logging +from contextlib import AbstractAsyncContextManager from datetime import timedelta from typing import Any, Callable, Optional, Sequence, Union @@ -24,6 +25,8 @@ class TemporalMCPServer(abc.ABC): + """A representation of an MCP server to be registered with the OpenAIAgentsPlugin.""" + @property @abc.abstractmethod def name(self) -> str: @@ -31,25 +34,8 @@ def name(self) -> str: raise NotImplementedError() -class StatelessTemporalMCPServerReference(MCPServer): - """A stateless MCP server implementation for Temporal workflows. - - This class wraps an MCP server to make it stateless by executing each MCP operation - as a separate Temporal activity. Each operation (list_tools, call_tool, etc.) will - connect to the underlying server, execute the operation, and then clean up the connection. - - This approach is suitable for simple use cases where connection overhead is acceptable - and you don't need to maintain state between operations. - """ - +class _StatelessTemporalMCPServerReference(MCPServer): def __init__(self, server: str, config: Optional[ActivityConfig] = None): - """Initialize the stateless temporal MCP server. - - Args: - server: Either an MCPServer instance or a string name for the server. - config: Optional activity configuration for Temporal activities. Defaults to - 1-minute start-to-close timeout if not provided. - """ self._name = server + "-stateless" self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1) @@ -58,27 +44,12 @@ def __init__(self, server: str, config: Optional[ActivityConfig] = None): @property def name(self) -> str: - """Get the server name with '-stateless' suffix. - - Returns: - The server name with '-stateless' appended. - """ return self._name async def connect(self) -> None: - """Connect to the MCP server. - - For stateless servers, this is a no-op since connections are made - on a per-operation basis. - """ pass async def cleanup(self) -> None: - """Clean up the MCP server connection. - - For stateless servers, this is a no-op since connections are cleaned - up after each operation. - """ pass async def list_tools( @@ -86,21 +57,6 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: - """List available tools from the MCP server. - - This method executes a Temporal activity to connect to the MCP server, - retrieve the list of available tools, and clean up the connection. - - Args: - run_context: Optional run context wrapper (unused in stateless mode). - agent: Optional agent base (unused in stateless mode). - - Returns: - A list of available MCP tools. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-list-tools", args=[], @@ -111,21 +67,6 @@ async def list_tools( async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - """Call a specific tool on the MCP server. - - This method executes a Temporal activity to connect to the MCP server, - call the specified tool with the given arguments, and clean up the connection. - - Args: - tool_name: The name of the tool to call. - arguments: Optional dictionary of arguments to pass to the tool. - - Returns: - The result of the tool call. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -134,17 +75,6 @@ async def call_tool( ) async def list_prompts(self) -> ListPromptsResult: - """List available prompts from the MCP server. - - This method executes a Temporal activity to connect to the MCP server, - retrieve the list of available prompts, and clean up the connection. - - Returns: - A list of available prompts. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -155,21 +85,6 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: - """Get a specific prompt from the MCP server. - - This method executes a Temporal activity to connect to the MCP server, - retrieve the specified prompt with optional arguments, and clean up the connection. - - Args: - name: The name of the prompt to retrieve. - arguments: Optional dictionary of arguments for the prompt. - - Returns: - The prompt result. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], @@ -287,22 +202,13 @@ async def wrapper(*args, **kwargs): return wrapper -class StatefulTemporalMCPServerReference(MCPServer): +class _StatefulTemporalMCPServerReference(MCPServer, AbstractAsyncContextManager): def __init__( self, server: str, config: Optional[ActivityConfig] = None, connect_config: Optional[ActivityConfig] = None, ): - """Initialize the stateful temporal MCP server. - - Args: - server: A string name for the server. Should match that provided in the plugin. - config: Optional activity configuration for MCP operation activities. - Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. - connect_config: Optional activity configuration for the connection activity. - Defaults to 1-hour start-to-close timeout. - """ self._name = server + "-stateful" self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1), @@ -316,20 +222,9 @@ def __init__( @property def name(self) -> str: - """Get the server name with '-stateful' suffix. - - Returns: - The server name with '-stateful' appended. - """ return self._name async def connect(self) -> None: - """Connect to the MCP server and start the dedicated worker. - - This method creates a dedicated task queue for this workflow and starts - a long-running activity that maintains the connection and runs a worker - to handle MCP operations. - """ self._config["task_queue"] = workflow.info().workflow_id + "-" + self.name self._connect_handle = workflow.start_activity( self.name + "-connect", @@ -338,32 +233,14 @@ async def connect(self) -> None: ) async def cleanup(self) -> None: - """Clean up the MCP server connection. - - This method cancels the long-running connection activity, which will - cause the dedicated worker to shut down and the MCP server connection - to be closed. - """ if self._connect_handle: self._connect_handle.cancel() async def __aenter__(self): - """Async context manager entry point. - - Returns: - This server instance after connecting. - """ await self.connect() return self async def __aexit__(self, exc_type, exc_value, traceback): - """Async context manager exit point. - - Args: - exc_type: Exception type if an exception occurred. - exc_value: Exception value if an exception occurred. - traceback: Exception traceback if an exception occurred. - """ await self.cleanup() @_handle_worker_failure @@ -372,22 +249,6 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: - """List available tools from the MCP server. - - This method executes a Temporal activity on the dedicated task queue - to retrieve the list of available tools from the persistent MCP connection. - - Args: - run_context: Optional run context wrapper (unused in stateful mode). - agent: Optional agent base (unused in stateful mode). - - Returns: - A list of available MCP tools. - - Raises: - ApplicationError: If the MCP worker fails to schedule or heartbeat. - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-list-tools", args=[], @@ -399,21 +260,6 @@ async def list_tools( async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - """Call a specific tool on the MCP server. - - This method executes a Temporal activity on the dedicated task queue - to call the specified tool using the persistent MCP connection. - - Args: - tool_name: The name of the tool to call. - arguments: Optional dictionary of arguments to pass to the tool. - - Returns: - The result of the tool call. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -423,17 +269,6 @@ async def call_tool( @_handle_worker_failure async def list_prompts(self) -> ListPromptsResult: - """List available prompts from the MCP server. - - This method executes a Temporal activity on the dedicated task queue - to retrieve the list of available prompts from the persistent MCP connection. - - Returns: - A list of available prompts. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -445,21 +280,6 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: - """Get a specific prompt from the MCP server. - - This method executes a Temporal activity on the dedicated task queue - to retrieve the specified prompt using the persistent MCP connection. - - Args: - name: The name of the prompt to retrieve. - arguments: Optional dictionary of arguments for the prompt. - - Returns: - The prompt result. - - Raises: - ActivityError: If the underlying Temporal activity fails. - """ return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index 2a81c6754..cd0584d4d 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -57,16 +57,16 @@ async def run( if starting_agent.mcp_servers: from temporalio.contrib.openai_agents._mcp import ( - StatefulTemporalMCPServerReference, - StatelessTemporalMCPServerReference, + _StatefulTemporalMCPServerReference, + _StatelessTemporalMCPServerReference, ) for s in starting_agent.mcp_servers: if not isinstance( s, ( - StatelessTemporalMCPServerReference, - StatefulTemporalMCPServerReference, + _StatelessTemporalMCPServerReference, + _StatefulTemporalMCPServerReference, ), ): warnings.warn( diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index cd99c5c5f..eea1c23ff 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -4,6 +4,7 @@ import inspect import json import typing +from contextlib import AbstractAsyncContextManager, asynccontextmanager from datetime import timedelta from typing import Any, Callable, Optional, Type @@ -21,8 +22,8 @@ from temporalio import workflow as temporal_workflow from temporalio.common import Priority, RetryPolicy from temporalio.contrib.openai_agents._mcp import ( - StatefulTemporalMCPServerReference, - StatelessTemporalMCPServerReference, + _StatefulTemporalMCPServerReference, + _StatelessTemporalMCPServerReference, ) from temporalio.exceptions import ApplicationError, TemporalError from temporalio.workflow import ( @@ -244,18 +245,55 @@ async def run_operation(ctx: RunContextWrapper[Any], input: str) -> Any: ) -def get_stateless_mcp_server( +def stateless_mcp_server( name: str, config: Optional[ActivityConfig] = None ) -> "MCPServer": - return StatelessTemporalMCPServerReference(name, config) + """A stateless MCP server implementation for Temporal workflows. + .. warning:: + This API is experimental and may change in future versions. + Use with caution in production environments. -def get_stateful_mcp_server( + This uses a TemporalMCPServer of the same name registered with the OpenAIAgents plugin to implement + durable MCP operations statelessly. + + This approach is suitable for simple use cases where connection overhead is acceptable + and you don't need to maintain state between operations. It should be preferred to stateful when possible due to its + superior durability guarantees. + """ + return _StatelessTemporalMCPServerReference(name, config) + + +def stateful_mcp_server( name: str, config: Optional[ActivityConfig] = None, connect_config: Optional[ActivityConfig] = None, -) -> "StatefulTemporalMCPServerReference": - return StatefulTemporalMCPServerReference(name, config, connect_config) +) -> AbstractAsyncContextManager["MCPServer"]: + """A stateful MCP server implementation for Temporal workflows. + + .. warning:: + This API is experimental and may change in future versions. + Use with caution in production environments. + + This wraps an MCP server to maintain a persistent connection throughout + the workflow execution. It creates a dedicated worker that stays connected to + the MCP server and processes operations on a dedicated task queue. + + This approach is more efficient for workflows that make multiple MCP calls, + as it avoids connection overhead, but requires more resources to maintain + the persistent connection and worker. + + The caller will have to handle cases where the dedicated worker fails, as Temporal is + unable to seamlessly recreate any lost state in that case. + + Args: + name: A string name for the server. Should match that provided in the plugin. + config: Optional activity configuration for MCP operation activities. + Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. + connect_config: Optional activity configuration for the connection activity. + Defaults to 1-hour start-to-close timeout. + """ + return _StatefulTemporalMCPServerReference(name, config, connect_config) class ToolSerializationError(TemporalError): diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 9fdeafad8..92f974931 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -59,6 +59,7 @@ TResponseOutputItem, TResponseStreamEvent, ) +from agents.mcp import MCPServerStreamableHttp from openai import APIStatusError, AsyncOpenAI, BaseModel from openai.types.responses import ( EasyInputMessageParam, @@ -2056,7 +2057,7 @@ class McpServerWorkflow: async def run(self, question: str) -> str: from agents.mcp import MCPServer - server: MCPServer = openai_agents.workflow.get_stateless_mcp_server( + server: MCPServer = openai_agents.workflow.stateless_mcp_server( "Filesystem-Server" ) agent = Agent[str]( @@ -2128,7 +2129,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): client = Client(**new_config) async with new_worker( - client, McpServerWorkflow, activities=[get_weather, get_weather] + client, McpServerWorkflow ) as worker: workflow_handle = await client.start_workflow( McpServerWorkflow.run, @@ -2146,7 +2147,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): class McpServerStatefulWorkflow: @workflow.run async def run(self, timeout: timedelta) -> str: - async with openai_agents.workflow.get_stateful_mcp_server( + async with openai_agents.workflow.stateful_mcp_server( "Filesystem-Server", config=ActivityConfig( schedule_to_start_timeout=timeout, From 09aa3f0c8944dac024373a868bce9481d98f3e72 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 19 Aug 2025 10:31:00 -0700 Subject: [PATCH 20/39] Lint --- tests/contrib/openai_agents/test_openai.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 92f974931..9d642ceec 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2128,9 +2128,7 @@ async def test_stateless_mcp_server(client: Client, use_local_model: bool): ] client = Client(**new_config) - async with new_worker( - client, McpServerWorkflow - ) as worker: + async with new_worker(client, McpServerWorkflow) as worker: workflow_handle = await client.start_workflow( McpServerWorkflow.run, "Read the files and list them.", From ab040c3c81c39634d03a71db95b8dfba461c2148 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 19 Aug 2025 11:09:54 -0700 Subject: [PATCH 21/39] Fixing python 3.9 --- .../contrib/openai_agents/_temporal_openai_agents.py | 11 +++++------ temporalio/contrib/openai_agents/workflow.py | 12 +++++++----- tests/contrib/openai_agents/test_openai.py | 1 - 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 08fe9771a..072450b7b 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,6 +1,7 @@ """Initialize Temporal OpenAI Agents overrides.""" import dataclasses +import typing from contextlib import asynccontextmanager, contextmanager from datetime import timedelta from typing import AsyncIterator, Callable, Optional, Sequence, Union @@ -26,12 +27,7 @@ import temporalio.client import temporalio.worker from temporalio.client import ClientConfig -from temporalio.contrib.openai_agents import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, -) from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity -from temporalio.contrib.openai_agents._mcp import TemporalMCPServer from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import TemporalOpenAIRunner from temporalio.contrib.openai_agents._temporal_trace_provider import ( @@ -62,6 +58,9 @@ except ImportError: pass +if typing.TYPE_CHECKING: + from temporalio.contrib.openai_agents._mcp import TemporalMCPServer + @contextmanager def set_open_ai_agent_temporal_overrides( @@ -237,7 +236,7 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence[TemporalMCPServer] = (), + mcp_servers: Sequence["TemporalMCPServer"] = (), ) -> None: """Initialize the OpenAI agents plugin. diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index eea1c23ff..f5d26f21a 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -4,7 +4,7 @@ import inspect import json import typing -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from contextlib import AbstractAsyncContextManager from datetime import timedelta from typing import Any, Callable, Optional, Type @@ -21,10 +21,6 @@ from temporalio import activity from temporalio import workflow as temporal_workflow from temporalio.common import Priority, RetryPolicy -from temporalio.contrib.openai_agents._mcp import ( - _StatefulTemporalMCPServerReference, - _StatelessTemporalMCPServerReference, -) from temporalio.exceptions import ApplicationError, TemporalError from temporalio.workflow import ( ActivityCancellationType, @@ -261,6 +257,9 @@ def stateless_mcp_server( and you don't need to maintain state between operations. It should be preferred to stateful when possible due to its superior durability guarantees. """ + from temporalio.contrib.openai_agents._mcp import ( + _StatelessTemporalMCPServerReference, + ) return _StatelessTemporalMCPServerReference(name, config) @@ -293,6 +292,9 @@ def stateful_mcp_server( connect_config: Optional activity configuration for the connection activity. Defaults to 1-hour start-to-close timeout. """ + from temporalio.contrib.openai_agents._mcp import ( + _StatefulTemporalMCPServerReference, + ) return _StatefulTemporalMCPServerReference(name, config, connect_config) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 9d642ceec..73876c90b 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -59,7 +59,6 @@ TResponseOutputItem, TResponseStreamEvent, ) -from agents.mcp import MCPServerStreamableHttp from openai import APIStatusError, AsyncOpenAI, BaseModel from openai.types.responses import ( EasyInputMessageParam, From 72fbeebf750d32c5b200b416b3800f77017a5893 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 19 Aug 2025 11:38:09 -0700 Subject: [PATCH 22/39] Lint --- temporalio/contrib/openai_agents/workflow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index f5d26f21a..9775a4cfd 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -260,6 +260,7 @@ def stateless_mcp_server( from temporalio.contrib.openai_agents._mcp import ( _StatelessTemporalMCPServerReference, ) + return _StatelessTemporalMCPServerReference(name, config) @@ -295,6 +296,7 @@ def stateful_mcp_server( from temporalio.contrib.openai_agents._mcp import ( _StatefulTemporalMCPServerReference, ) + return _StatefulTemporalMCPServerReference(name, config, connect_config) From 24aecd9a024af380307a5df0f5fb493f578936cb Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Tue, 19 Aug 2025 15:17:07 -0700 Subject: [PATCH 23/39] Some name changes and protected get_activities --- temporalio/contrib/openai_agents/_mcp.py | 14 +++++++------- .../openai_agents/_temporal_openai_agents.py | 15 ++++++++------- temporalio/contrib/openai_agents/workflow.py | 6 +++--- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 7b69db1f1..56d60a4e4 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -120,7 +120,7 @@ def name(self) -> str: """Get the server name.""" return self._name - def get_activities(self) -> Sequence[Callable]: + def _get_activities(self) -> Sequence[Callable]: """Get the Temporal activities for this MCP server. Creates and returns the Temporal activity functions that handle MCP operations. @@ -207,14 +207,14 @@ def __init__( self, server: str, config: Optional[ActivityConfig] = None, - connect_config: Optional[ActivityConfig] = None, + server_session_config: Optional[ActivityConfig] = None, ): self._name = server + "-stateful" self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1), schedule_to_start_timeout=timedelta(seconds=30), ) - self._connect_config = connect_config or ActivityConfig( + self._server_session_config = server_session_config or ActivityConfig( start_to_close_timeout=timedelta(hours=1), ) self._connect_handle: Optional[ActivityHandle] = None @@ -227,9 +227,9 @@ def name(self) -> str: async def connect(self) -> None: self._config["task_queue"] = workflow.info().workflow_id + "-" + self.name self._connect_handle = workflow.start_activity( - self.name + "-connect", + self.name + "-server-session", args=[], - **self._connect_config, + **self._server_session_config, ) async def cleanup(self) -> None: @@ -324,7 +324,7 @@ def name(self) -> str: """Get the server name.""" return self._name - def get_activities(self) -> Sequence[Callable]: + def _get_activities(self) -> Sequence[Callable]: """Get the Temporal activities for this stateful MCP server. Creates and returns the Temporal activity functions that handle MCP operations @@ -365,7 +365,7 @@ async def heartbeat_every(delay: float, *details: Any) -> None: await asyncio.sleep(delay) activity.heartbeat(*details) - @activity.defn(name=self._name + "-connect") + @activity.defn(name=self._name + "-server-session") async def connect() -> None: heartbeat_task = asyncio.create_task(heartbeat_every(30)) try: diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 072450b7b..23ffb31f8 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -59,7 +59,10 @@ pass if typing.TYPE_CHECKING: - from temporalio.contrib.openai_agents._mcp import TemporalMCPServer + from temporalio.contrib.openai_agents import ( + StatefulTemporalMCPServer, + StatelessTemporalMCPServer, + ) @contextmanager @@ -236,7 +239,9 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence["TemporalMCPServer"] = (), + mcp_servers: Sequence[ + Union["StatelessTemporalMCPServer", "StatefulTemporalMCPServer"] + ] = (), ) -> None: """Initialize the OpenAI agents plugin. @@ -327,11 +332,7 @@ def configure_worker(self, config: WorkerConfig) -> WorkerConfig: ) for mcp_server in self._mcp_servers: - if hasattr(mcp_server, "get_activities"): - get_activities: Callable[[], Sequence[Callable]] = getattr( - mcp_server, "get_activities" - ) - new_activities.extend(get_activities()) + new_activities.extend(mcp_server._get_activities()) config["activities"] = list(config.get("activities") or []) + new_activities runner = config.get("workflow_runner") diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index 9775a4cfd..a062e20f1 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -267,7 +267,7 @@ def stateless_mcp_server( def stateful_mcp_server( name: str, config: Optional[ActivityConfig] = None, - connect_config: Optional[ActivityConfig] = None, + server_session_config: Optional[ActivityConfig] = None, ) -> AbstractAsyncContextManager["MCPServer"]: """A stateful MCP server implementation for Temporal workflows. @@ -290,14 +290,14 @@ def stateful_mcp_server( name: A string name for the server. Should match that provided in the plugin. config: Optional activity configuration for MCP operation activities. Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. - connect_config: Optional activity configuration for the connection activity. + server_session_config: Optional activity configuration for the connection activity. Defaults to 1-hour start-to-close timeout. """ from temporalio.contrib.openai_agents._mcp import ( _StatefulTemporalMCPServerReference, ) - return _StatefulTemporalMCPServerReference(name, config, connect_config) + return _StatefulTemporalMCPServerReference(name, config, server_session_config) class ToolSerializationError(TemporalError): From 3c847b654e5511eeace13db4ae504e850d6de4c4 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 20 Aug 2025 09:11:34 -0700 Subject: [PATCH 24/39] Addressing feedback --- temporalio/contrib/openai_agents/_mcp.py | 101 ++++++------------ .../contrib/openai_agents/_openai_runner.py | 6 +- 2 files changed, 34 insertions(+), 73 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 56d60a4e4..090cb2838 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -24,16 +24,6 @@ logger = logging.getLogger(__name__) -class TemporalMCPServer(abc.ABC): - """A representation of an MCP server to be registered with the OpenAIAgentsPlugin.""" - - @property - @abc.abstractmethod - def name(self) -> str: - """Get the server name.""" - raise NotImplementedError() - - class _StatelessTemporalMCPServerReference(MCPServer): def __init__(self, server: str, config: Optional[ActivityConfig] = None): self._name = server + "-stateless" @@ -93,16 +83,15 @@ async def get_prompt( ) -class StatelessTemporalMCPServer(TemporalMCPServer): +class StatelessTemporalMCPServer: """A stateless MCP server implementation for Temporal workflows. This class wraps an MCP server to make it stateless by executing each MCP operation as a separate Temporal activity. Each operation (list_tools, call_tool, etc.) will connect to the underlying server, execute the operation, and then clean up the connection. - This approach is suitable for simple use cases where connection overhead is acceptable - and you don't need to maintain state between operations. It is encouraged when possible as it provides - a better set of durability guarantees that the stateful version. + This approach will not maintain state across calls. If the desired MCPServer needs persistent state in order to + function, this cannot be used. """ def __init__(self, server: MCPServer): @@ -121,54 +110,41 @@ def name(self) -> str: return self._name def _get_activities(self) -> Sequence[Callable]: - """Get the Temporal activities for this MCP server. - - Creates and returns the Temporal activity functions that handle MCP operations. - Each activity manages its own connection lifecycle (connect -> operate -> cleanup). - - Returns: - A sequence of Temporal activity functions. - - Raises: - ValueError: If no MCP server instance was provided during initialization. - """ - server = self._server - @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: try: - await server.connect() - return await server.list_tools() + await self._server.connect() + return await self._server.list_tools() finally: - await server.cleanup() + await self._server.cleanup() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: try: - await server.connect() - return await server.call_tool(tool_name, arguments) + await self._server.connect() + return await self._server.call_tool(tool_name, arguments) finally: - await server.cleanup() + await self._server.cleanup() @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: try: - await server.connect() - return await server.list_prompts() + await self._server.connect() + return await self._server.list_prompts() finally: - await server.cleanup() + await self._server.cleanup() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: try: - await server.connect() - return await server.get_prompt(name, arguments) + await self._server.connect() + return await self._server.get_prompt(name, arguments) finally: - await server.cleanup() + await self._server.cleanup() return list_tools, call_tool, list_prompts, get_prompt @@ -188,14 +164,16 @@ async def wrapper(*args, **kwargs): == TIMEOUT_TYPE_SCHEDULE_TO_START ): raise ApplicationError( - "MCP Stateful Server Worker failed to schedule activity." + "MCP Stateful Server Worker failed to schedule activity.", + type="DedicatedWorkerFailure", ) from e if ( cause.timeout_failure_info.timeout_type == TIMEOUT_TYPE_HEARTBEAT ): raise ApplicationError( - "MCP Stateful Server Worker failed to heartbeat." + "MCP Stateful Server Worker failed to heartbeat.", + type="DedicatedWorkerFailure", ) from e raise e @@ -288,19 +266,20 @@ async def get_prompt( ) -class StatefulTemporalMCPServer(TemporalMCPServer): +class StatefulTemporalMCPServer: """A stateful MCP server implementation for Temporal workflows. This class wraps an MCP server to maintain a persistent connection throughout the workflow execution. It creates a dedicated worker that stays connected to the MCP server and processes operations on a dedicated task queue. - This approach is more efficient for workflows that make multiple MCP calls, - as it avoids connection overhead, but requires more resources to maintain - the persistent connection and worker. + This approach will allow the MCPServer to maintain state across calls if needed, but the caller + will have to handle cases where the dedicated worker fails, as Temporal is unable to seamlessly + recreate any lost state in that case. It is discouraged to use this approach unless necessary. - The caller will have to handle cases where the dedicated worker fails, as Temporal is - unable to seamlessly recreate any lost state in that case. + Handling dedicated worker failure will entail catching ApplicationError with type "DedicatedWorkerFailure". + Depending on the usage pattern, the caller will then have to either restart from the point at which the Stateful + server was needed or handle continuing from that loss of state in some other way. """ def __init__( @@ -311,8 +290,6 @@ def __init__( Args: server: Either an MCPServer instance or a string name for the server. - connect_config: Optional activity configuration for the connection activity. - Defaults to 1-hour start-to-close timeout. """ self._server = server self._name = self._server.name + "-stateful" @@ -325,39 +302,25 @@ def name(self) -> str: return self._name def _get_activities(self) -> Sequence[Callable]: - """Get the Temporal activities for this stateful MCP server. - - Creates and returns the Temporal activity functions that handle MCP operations - and connection management. This includes a long-running connect activity that - maintains the MCP connection and runs a dedicated worker. - - Returns: - A sequence containing the connect activity function. - - Raises: - ValueError: If no MCP server instance was provided during initialization. - """ - server = self._server - @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: - return await server.list_tools() + return await self._server.list_tools() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - return await server.call_tool(tool_name, arguments) + return await self._server.call_tool(tool_name, arguments) @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: - return await server.list_prompts() + return await self._server.list_prompts() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: - return await server.get_prompt(name, arguments) + return await self._server.get_prompt(name, arguments) async def heartbeat_every(delay: float, *details: Any) -> None: """Heartbeat every so often while not cancelled""" @@ -369,7 +332,7 @@ async def heartbeat_every(delay: float, *details: Any) -> None: async def connect() -> None: heartbeat_task = asyncio.create_task(heartbeat_every(30)) try: - await server.connect() + await self._server.connect() worker = Worker( activity.client(), @@ -380,7 +343,7 @@ async def connect() -> None: await worker.run() finally: - await server.cleanup() + await self._server.cleanup() heartbeat_task.cancel() try: await heartbeat_task diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index cd0584d4d..bf3bf9fa7 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -69,10 +69,8 @@ async def run( _StatefulTemporalMCPServerReference, ), ): - warnings.warn( - "Unknown mcp_server type {} may not work durably.".format( - type(s) - ) + raise ValueError( + f"Unknown mcp_server type {type(s)} may not work durably." ) # workaround for https://github.com/pydantic/pydantic/issues/9541 From 6f02b017aca3917f54979eed27c8c8f1e71e3406 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 20 Aug 2025 09:55:00 -0700 Subject: [PATCH 25/39] Change server names, overhaul tests to use a custom MCPServer which tracks calls --- temporalio/contrib/openai_agents/__init__.py | 8 +- temporalio/contrib/openai_agents/_mcp.py | 9 +- .../contrib/openai_agents/_openai_runner.py | 8 +- .../openai_agents/_temporal_openai_agents.py | 12 +- temporalio/contrib/openai_agents/workflow.py | 8 +- tests/contrib/openai_agents/test_openai.py | 257 ++++++++++-------- 6 files changed, 168 insertions(+), 134 deletions(-) diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index facb877d3..6debcde16 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -11,8 +11,8 @@ # Best Effort mcp, as it is not supported on Python 3.9 try: from temporalio.contrib.openai_agents._mcp import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, + StatefulMCPServer, + StatelessMCPServer, ) except ImportError: pass @@ -33,8 +33,8 @@ "OpenAIAgentsPlugin", "ModelActivityParameters", "workflow", - "StatelessTemporalMCPServer", - "StatefulTemporalMCPServer", + "StatelessMCPServer", + "StatefulMCPServer", "TestModel", "TestModelProvider", ] diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 090cb2838..ddf8cdc35 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) -class _StatelessTemporalMCPServerReference(MCPServer): +class _StatelessMCPServerReference(MCPServer): def __init__(self, server: str, config: Optional[ActivityConfig] = None): self._name = server + "-stateless" self._config = config or ActivityConfig( @@ -83,7 +83,7 @@ async def get_prompt( ) -class StatelessTemporalMCPServer: +class StatelessMCPServer: """A stateless MCP server implementation for Temporal workflows. This class wraps an MCP server to make it stateless by executing each MCP operation @@ -180,7 +180,7 @@ async def wrapper(*args, **kwargs): return wrapper -class _StatefulTemporalMCPServerReference(MCPServer, AbstractAsyncContextManager): +class _StatefulMCPServerReference(MCPServer, AbstractAsyncContextManager): def __init__( self, server: str, @@ -266,7 +266,7 @@ async def get_prompt( ) -class StatefulTemporalMCPServer: +class StatefulMCPServer: """A stateful MCP server implementation for Temporal workflows. This class wraps an MCP server to maintain a persistent connection throughout @@ -343,6 +343,7 @@ async def connect() -> None: await worker.run() finally: + print("Cleanup") await self._server.cleanup() heartbeat_task.cancel() try: diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index bf3bf9fa7..d0108309b 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -57,16 +57,16 @@ async def run( if starting_agent.mcp_servers: from temporalio.contrib.openai_agents._mcp import ( - _StatefulTemporalMCPServerReference, - _StatelessTemporalMCPServerReference, + _StatefulMCPServerReference, + _StatelessMCPServerReference, ) for s in starting_agent.mcp_servers: if not isinstance( s, ( - _StatelessTemporalMCPServerReference, - _StatefulTemporalMCPServerReference, + _StatelessMCPServerReference, + _StatefulMCPServerReference, ), ): raise ValueError( diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 23ffb31f8..656eb2d8d 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -60,8 +60,8 @@ if typing.TYPE_CHECKING: from temporalio.contrib.openai_agents import ( - StatefulTemporalMCPServer, - StatelessTemporalMCPServer, + StatefulMCPServer, + StatelessMCPServer, ) @@ -201,7 +201,7 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): Example: >>> from temporalio.client import Client >>> from temporalio.worker import Worker - >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessTemporalMCPServer + >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessMCPServer >>> from agents.mcp import MCPServerStdio >>> from datetime import timedelta >>> @@ -212,7 +212,7 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): ... ) >>> >>> # Create MCP servers - >>> filesystem_server = StatelessTemporalMCPServer(MCPServerStdio( + >>> filesystem_server = StatelessMCPServer(MCPServerStdio( ... name="Filesystem Server", ... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]} ... )) @@ -239,9 +239,7 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence[ - Union["StatelessTemporalMCPServer", "StatefulTemporalMCPServer"] - ] = (), + mcp_servers: Sequence[Union["StatelessMCPServer", "StatefulMCPServer"]] = (), ) -> None: """Initialize the OpenAI agents plugin. diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index a062e20f1..78216e372 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -258,10 +258,10 @@ def stateless_mcp_server( superior durability guarantees. """ from temporalio.contrib.openai_agents._mcp import ( - _StatelessTemporalMCPServerReference, + _StatelessMCPServerReference, ) - return _StatelessTemporalMCPServerReference(name, config) + return _StatelessMCPServerReference(name, config) def stateful_mcp_server( @@ -294,10 +294,10 @@ def stateful_mcp_server( Defaults to 1-hour start-to-close timeout. """ from temporalio.contrib.openai_agents._mcp import ( - _StatefulTemporalMCPServerReference, + _StatefulMCPServerReference, ) - return _StatefulTemporalMCPServerReference(name, config, server_session_config) + return _StatefulMCPServerReference(name, config, server_session_config) class ToolSerializationError(TemporalError): diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 73876c90b..df9bba1c9 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -19,6 +19,7 @@ import pytest from agents import ( Agent, + AgentBase, AgentOutputSchemaBase, CodeInterpreterTool, FileSearchTool, @@ -88,6 +89,7 @@ from temporalio.contrib import openai_agents from temporalio.contrib.openai_agents import ( ModelActivityParameters, + StatelessMCPServer, TestModel, TestModelProvider, ) @@ -2053,91 +2055,19 @@ async def test_hosted_mcp_tool(client: Client, use_local_model): @workflow.defn class McpServerWorkflow: @workflow.run - async def run(self, question: str) -> str: + async def run(self, timeout: timedelta) -> str: from agents.mcp import MCPServer - server: MCPServer = openai_agents.workflow.stateless_mcp_server( - "Filesystem-Server" - ) + server: MCPServer = openai_agents.workflow.stateless_mcp_server("HelloServer") agent = Agent[str]( name="MCP ServerWorkflow", - instructions="Use the tools to read the filesystem and answer questions based on those files.", - mcp_servers=[server], - ) - result = await Runner.run(starting_agent=agent, input=question) - return result.final_output - - -class McpServerModel(StaticTestModel): - responses = [ - ResponseBuilders.tool_call( - arguments='{"path":"/"}', - name="list_directory", - ), - ResponseBuilders.tool_call( - arguments="{}", - name="list_allowed_directories", - ), - ResponseBuilders.tool_call( - arguments='{"path":"."}', - name="list_directory", - ), - ResponseBuilders.output_message( - "Here are the files and directories in the allowed path." - ), - ] - - -@pytest.mark.parametrize("use_local_model", [True, False]) -async def test_stateless_mcp_server(client: Client, use_local_model: bool): - if not use_local_model and not os.environ.get("OPENAI_API_KEY"): - pytest.skip("No openai API key") - - if sys.version_info < (3, 10): - pytest.skip("Mcp not supported on Python 3.9") - from agents.mcp import MCPServerStdio - - from temporalio.contrib.openai_agents import StatelessTemporalMCPServer - - server = StatelessTemporalMCPServer( - MCPServerStdio( - name="Filesystem-Server", - params={ - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - os.path.dirname(os.path.abspath(__file__)), - ], - }, - ) - ) - - new_config = client.config() - new_config["plugins"] = [ - openai_agents.OpenAIAgentsPlugin( - model_params=ModelActivityParameters( - start_to_close_timeout=timedelta(seconds=120) - ), - model_provider=TestModelProvider(McpServerModel()) - if use_local_model - else None, + instructions="Use the tools to assist the customer.", mcp_servers=[server], ) - ] - client = Client(**new_config) - - async with new_worker(client, McpServerWorkflow) as worker: - workflow_handle = await client.start_workflow( - McpServerWorkflow.run, - "Read the files and list them.", - id=f"mcp-server-{uuid.uuid4()}", - task_queue=worker.task_queue, - execution_timeout=timedelta(seconds=30), + result = await Runner.run( + starting_agent=agent, input="Say hello to Tom and Tim." ) - result = await workflow_handle.result() - if use_local_model: - assert result == "Here are the files and directories in the allowed path." + return result.final_output @workflow.defn @@ -2145,7 +2075,7 @@ class McpServerStatefulWorkflow: @workflow.run async def run(self, timeout: timedelta) -> str: async with openai_agents.workflow.stateful_mcp_server( - "Filesystem-Server", + "HelloServer", config=ActivityConfig( schedule_to_start_timeout=timeout, start_to_close_timeout=timedelta(seconds=30), @@ -2153,38 +2083,106 @@ async def run(self, timeout: timedelta) -> str: ) as server: agent = Agent[str]( name="MCP ServerWorkflow", - instructions="Use the tools to read the filesystem and answer questions based on those files.", + instructions="Use the tools to assist the customer.", mcp_servers=[server], ) result = await Runner.run( - starting_agent=agent, input="Read the files and list them." + starting_agent=agent, input="Say hello to Tom and Tim." ) return result.final_output +class TrackingMCPModel(StaticTestModel): + responses = [ + ResponseBuilders.tool_call( + arguments='{"name":"Tom"}', + name="Say-Hello", + ), + ResponseBuilders.tool_call( + arguments='{"name":"Tim"}', + name="Say-Hello", + ), + ResponseBuilders.output_message("Hi Tom and Tim!"), + ] + + @pytest.mark.parametrize("use_local_model", [True, False]) -async def test_stateful_mcp_server(client: Client, use_local_model: bool): +@pytest.mark.parametrize("stateful", [True, False]) +async def test_stateful_mcp_server( + client: Client, use_local_model: bool, stateful: bool +): if not use_local_model and not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") - from agents.mcp import MCPServerStdio + from agents.mcp import MCPServer + from mcp import GetPromptResult, ListPromptsResult + from mcp import Tool as MCPTool + from mcp.types import CallToolResult, TextContent + + from temporalio.contrib.openai_agents import StatefulMCPServer + + class TrackingMCPServer(MCPServer): + calls: list[str] + + def __init__(self, name: str): + self._name = name + self.calls = [] + super().__init__() + + async def connect(self): + self.calls.append("connect") + + @property + def name(self) -> str: + return self._name + + async def cleanup(self): + self.calls.append("cleanup") + + async def list_tools( + self, + run_context: Optional[RunContextWrapper[Any]] = None, + agent: Optional[AgentBase] = None, + ) -> list[MCPTool]: + self.calls.append("list_tools") + return [ + MCPTool( + name="Say-Hello", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"], + "$schema": "http://json-schema.org/draft-07/schema#", + }, + ) + ] + + async def call_tool( + self, tool_name: str, arguments: Optional[dict[str, Any]] + ) -> CallToolResult: + self.calls.append("call_tool") + name = (arguments or {}).get("name") or "John Doe" + return CallToolResult( + content=[TextContent(type="text", text=f"Hello {name}")] + ) - from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + async def list_prompts(self) -> ListPromptsResult: + raise NotImplementedError() - server = StatefulTemporalMCPServer( - MCPServerStdio( - name="Filesystem-Server", - params={ - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - os.path.dirname(os.path.abspath(__file__)), - ], - }, - ) + async def get_prompt( + self, name: str, arguments: Optional[dict[str, Any]] = None + ) -> GetPromptResult: + raise NotImplementedError() + + tracking_server = TrackingMCPServer(name="HelloServer") + server: Union[StatefulMCPServer, StatelessMCPServer] = ( + StatefulMCPServer(tracking_server) + if stateful + else StatelessMCPServer(tracking_server) ) new_config = client.config() @@ -2193,7 +2191,7 @@ async def test_stateful_mcp_server(client: Client, use_local_model: bool): model_params=ModelActivityParameters( start_to_close_timeout=timedelta(seconds=120) ), - model_provider=TestModelProvider(McpServerModel()) + model_provider=TestModelProvider(TrackingMCPModel()) if use_local_model else None, mcp_servers=[server], @@ -2202,19 +2200,56 @@ async def test_stateful_mcp_server(client: Client, use_local_model: bool): client = Client(**new_config) async with new_worker( - client, - McpServerStatefulWorkflow, + client, McpServerStatefulWorkflow, McpServerWorkflow ) as worker: - workflow_handle = await client.start_workflow( - McpServerStatefulWorkflow.run, - timedelta(seconds=30), - id=f"mcp-server-{uuid.uuid4()}", - task_queue=worker.task_queue, - execution_timeout=timedelta(seconds=30), - ) - result = await workflow_handle.result() + if stateful: + result = client.execute_workflow( + McpServerStatefulWorkflow.run, + timedelta(seconds=30), + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) + else: + result = client.execute_workflow( + McpServerWorkflow.run, + timedelta(seconds=30), + id=f"mcp-server-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=30), + ) if use_local_model: - assert result == "Here are the files and directories in the allowed path." + assert result == "Hi Tom and Tim!" + if use_local_model: + print(tracking_server.calls) + if stateful: + assert tracking_server.calls == [ + "connect", + "list_tools", + "call_tool", + "list_tools", + "call_tool", + "list_tools", + "cleanup", + ] + else: + assert tracking_server.calls == [ + "connect", + "list_tools", + "cleanup", + "connect", + "call_tool", + "cleanup", + "connect", + "list_tools", + "cleanup", + "connect", + "call_tool", + "cleanup", + "connect", + "list_tools", + "cleanup", + ] async def test_stateful_mcp_server_no_worker(client: Client): @@ -2222,9 +2257,9 @@ async def test_stateful_mcp_server_no_worker(client: Client): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServerStdio - from temporalio.contrib.openai_agents import StatefulTemporalMCPServer + from temporalio.contrib.openai_agents import StatefulMCPServer - server = StatefulTemporalMCPServer( + server = StatefulMCPServer( MCPServerStdio( name="Filesystem-Server", params={ @@ -2254,7 +2289,7 @@ def override_get_activities() -> Sequence[Callable]: model_params=ModelActivityParameters( start_to_close_timeout=timedelta(seconds=120) ), - model_provider=TestModelProvider(McpServerModel()), + model_provider=TestModelProvider(TrackingMCPModel()), mcp_servers=[server], ) ] From a03486786a5ed60c67bb5c0ef5bffd660a6133f6 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 20 Aug 2025 09:59:57 -0700 Subject: [PATCH 26/39] Fixing 3.9 issues --- tests/contrib/openai_agents/test_openai.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index df9bba1c9..91f2bb340 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -89,7 +89,6 @@ from temporalio.contrib import openai_agents from temporalio.contrib.openai_agents import ( ModelActivityParameters, - StatelessMCPServer, TestModel, TestModelProvider, ) @@ -2117,11 +2116,11 @@ async def test_stateful_mcp_server( if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServer - from mcp import GetPromptResult, ListPromptsResult - from mcp import Tool as MCPTool - from mcp.types import CallToolResult, TextContent + from mcp import GetPromptResult, ListPromptsResult # type: ignore + from mcp import Tool as MCPTool # type: ignore + from mcp.types import CallToolResult, TextContent # type: ignore - from temporalio.contrib.openai_agents import StatefulMCPServer + from temporalio.contrib.openai_agents import StatefulMCPServer, StatelessMCPServer class TrackingMCPServer(MCPServer): calls: list[str] From 783eb9eeae95bd64ed133781ac42bf4808edbee1 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 20 Aug 2025 12:11:37 -0700 Subject: [PATCH 27/39] Fix await --- temporalio/bridge/sdk-core | 2 +- tests/contrib/openai_agents/test_openai.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 871b320c8..45687c309 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 +Subproject commit 45687c309cfaec0d6e5d6542fdb6942eaae03180 diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 91f2bb340..f3df84302 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2202,7 +2202,7 @@ async def get_prompt( client, McpServerStatefulWorkflow, McpServerWorkflow ) as worker: if stateful: - result = client.execute_workflow( + result = await client.execute_workflow( McpServerStatefulWorkflow.run, timedelta(seconds=30), id=f"mcp-server-{uuid.uuid4()}", @@ -2210,7 +2210,7 @@ async def get_prompt( execution_timeout=timedelta(seconds=30), ) else: - result = client.execute_workflow( + result = await client.execute_workflow( McpServerWorkflow.run, timedelta(seconds=30), id=f"mcp-server-{uuid.uuid4()}", From 4c54c421b75c489d3e1f75abb115c2f9924b3a9e Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 20 Aug 2025 15:27:29 -0700 Subject: [PATCH 28/39] Revert core change --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 45687c309..871b320c8 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 45687c309cfaec0d6e5d6542fdb6942eaae03180 +Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 From a2720459e5a2c9bb34ddc6ee48b7b161d8fb905f Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 25 Aug 2025 10:42:13 -0700 Subject: [PATCH 29/39] Remove print --- temporalio/contrib/openai_agents/_mcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index ddf8cdc35..fbee3b467 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -343,7 +343,6 @@ async def connect() -> None: await worker.run() finally: - print("Cleanup") await self._server.cleanup() heartbeat_task.cancel() try: From d0e735538c7679fef123dfee6308cf473e43b92a Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 17 Sep 2025 08:56:18 -0700 Subject: [PATCH 30/39] Fail fast if stateful server hasn't been started --- temporalio/contrib/openai_agents/_mcp.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index fbee3b467..db645e77f 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -227,6 +227,8 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: + if not self._connect_handle: + raise ApplicationError("Stateful MCP Server not connected. Call connect first.") return await workflow.execute_activity( self.name + "-list-tools", args=[], @@ -238,6 +240,8 @@ async def list_tools( async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: + if not self._connect_handle: + raise ApplicationError("Stateful MCP Server not connected. Call connect first.") return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -247,6 +251,8 @@ async def call_tool( @_handle_worker_failure async def list_prompts(self) -> ListPromptsResult: + if not self._connect_handle: + raise ApplicationError("Stateful MCP Server not connected. Call connect first.") return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -258,6 +264,8 @@ async def list_prompts(self) -> ListPromptsResult: async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: + if not self._connect_handle: + raise ApplicationError("Stateful MCP Server not connected. Call connect first.") return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], From 8ef444b2bac08a955bfaa5c0a261a716109d39b9 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Sep 2025 11:19:32 -0700 Subject: [PATCH 31/39] Overhaul stateful mcp server --- temporalio/contrib/openai_agents/__init__.py | 4 +- temporalio/contrib/openai_agents/_mcp.py | 84 ++++++++++++------- .../openai_agents/_temporal_openai_agents.py | 6 +- tests/contrib/openai_agents/test_openai.py | 19 +++-- 4 files changed, 70 insertions(+), 43 deletions(-) diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index ce43e41ec..c6478cb1f 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -11,7 +11,7 @@ # Best Effort mcp, as it is not supported on Python 3.9 try: from temporalio.contrib.openai_agents._mcp import ( - StatefulMCPServer, + StatefulMCPServerProvider, StatelessMCPServer, ) except ImportError: @@ -37,7 +37,7 @@ "OpenAIAgentsPlugin", "OpenAIPayloadConverter", "StatelessMCPServer", - "StatefulMCPServer", + "StatefulMCPServerProvider", "TestModel", "TestModelProvider", "workflow", diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index db645e77f..1c694a896 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -203,7 +203,7 @@ def name(self) -> str: return self._name async def connect(self) -> None: - self._config["task_queue"] = workflow.info().workflow_id + "-" + self.name + self._config["task_queue"] = self.name + "@" + workflow.info().run_id self._connect_handle = workflow.start_activity( self.name + "-server-session", args=[], @@ -228,7 +228,9 @@ async def list_tools( agent: Optional[AgentBase] = None, ) -> list[MCPTool]: if not self._connect_handle: - raise ApplicationError("Stateful MCP Server not connected. Call connect first.") + raise ApplicationError( + "Stateful MCP Server not connected. Call connect first." + ) return await workflow.execute_activity( self.name + "-list-tools", args=[], @@ -241,7 +243,9 @@ async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: if not self._connect_handle: - raise ApplicationError("Stateful MCP Server not connected. Call connect first.") + raise ApplicationError( + "Stateful MCP Server not connected. Call connect first." + ) return await workflow.execute_activity( self.name + "-call-tool", args=[tool_name, arguments], @@ -252,7 +256,9 @@ async def call_tool( @_handle_worker_failure async def list_prompts(self) -> ListPromptsResult: if not self._connect_handle: - raise ApplicationError("Stateful MCP Server not connected. Call connect first.") + raise ApplicationError( + "Stateful MCP Server not connected. Call connect first." + ) return await workflow.execute_activity( self.name + "-list-prompts", args=[], @@ -265,7 +271,9 @@ async def get_prompt( self, name: str, arguments: Optional[dict[str, Any]] = None ) -> GetPromptResult: if not self._connect_handle: - raise ApplicationError("Stateful MCP Server not connected. Call connect first.") + raise ApplicationError( + "Stateful MCP Server not connected. Call connect first." + ) return await workflow.execute_activity( self.name + "-get-prompt", args=[name, arguments], @@ -274,10 +282,10 @@ async def get_prompt( ) -class StatefulMCPServer: +class StatefulMCPServerProvider: """A stateful MCP server implementation for Temporal workflows. - This class wraps an MCP server to maintain a persistent connection throughout + This class wraps an function to create MCP servers to maintain a persistent connection throughout the workflow execution. It creates a dedicated worker that stays connected to the MCP server and processes operations on a dedicated task queue. @@ -292,16 +300,18 @@ class StatefulMCPServer: def __init__( self, - server: MCPServer, + server_factory: Callable[[], MCPServer], ): """Initialize the stateful temporal MCP server. Args: - server: Either an MCPServer instance or a string name for the server. + server_factory: A function which will produce MCPServer instances. It should return a new server each time + so that state is not shared between workflow runs """ - self._server = server - self._name = self._server.name + "-stateful" + self._server_factory = server_factory + self._name = server_factory().name + "-stateful" self._connect_handle: Optional[ActivityHandle] = None + self._servers: dict[str, MCPServer] = {} super().__init__() @property @@ -310,25 +320,28 @@ def name(self) -> str: return self._name def _get_activities(self) -> Sequence[Callable]: + def _server_id(): + return self.name + "@" + activity.info().workflow_run_id + @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: - return await self._server.list_tools() + return await self._servers[_server_id()].list_tools() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: - return await self._server.call_tool(tool_name, arguments) + return await self._servers[_server_id()].call_tool(tool_name, arguments) @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: - return await self._server.list_prompts() + return await self._servers[_server_id()].list_prompts() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: - return await self._server.get_prompt(name, arguments) + return await self._servers[_server_id()].get_prompt(name, arguments) async def heartbeat_every(delay: float, *details: Any) -> None: """Heartbeat every so often while not cancelled""" @@ -339,23 +352,34 @@ async def heartbeat_every(delay: float, *details: Any) -> None: @activity.defn(name=self._name + "-server-session") async def connect() -> None: heartbeat_task = asyncio.create_task(heartbeat_every(30)) - try: - await self._server.connect() - worker = Worker( - activity.client(), - task_queue=activity.info().workflow_id + "-" + self.name, - activities=[list_tools, call_tool, list_prompts, get_prompt], - activity_task_poller_behavior=PollerBehaviorSimpleMaximum(1), + server_id = self.name + "@" + activity.info().workflow_run_id + if server_id in self._servers: + raise ApplicationError( + "Cannot connect to an already running server. Use a distinct name if running multiple servers in one workflow." ) - - await worker.run() - finally: - await self._server.cleanup() - heartbeat_task.cancel() + server = self._server_factory() + try: + self._servers[server_id] = server try: - await heartbeat_task - except asyncio.CancelledError: - pass + await server.connect() + + worker = Worker( + activity.client(), + task_queue=server_id, + activities=[list_tools, call_tool, list_prompts, get_prompt], + activity_task_poller_behavior=PollerBehaviorSimpleMaximum(1), + ) + + await worker.run() + finally: + await server.cleanup() + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + finally: + del self._servers[server_id] return (connect,) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 4adc7873c..2d4b982ef 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -64,7 +64,7 @@ if typing.TYPE_CHECKING: from temporalio.contrib.openai_agents import ( - StatefulMCPServer, + StatefulMCPServerProvider, StatelessMCPServer, ) @@ -242,7 +242,9 @@ def __init__( self, model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, - mcp_servers: Sequence[Union["StatelessMCPServer", "StatefulMCPServer"]] = (), + mcp_servers: Sequence[ + Union["StatelessMCPServer", "StatefulMCPServerProvider"] + ] = (), ) -> None: """Initialize the OpenAI agents plugin. diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 5f11c6fef..95ace6242 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2341,9 +2341,7 @@ class TrackingMCPModel(StaticTestModel): @pytest.mark.parametrize("use_local_model", [True, False]) @pytest.mark.parametrize("stateful", [True, False]) -async def test_stateful_mcp_server( - client: Client, use_local_model: bool, stateful: bool -): +async def test_mcp_server(client: Client, use_local_model: bool, stateful: bool): if not use_local_model and not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") @@ -2354,7 +2352,10 @@ async def test_stateful_mcp_server( from mcp import Tool as MCPTool # type: ignore from mcp.types import CallToolResult, TextContent # type: ignore - from temporalio.contrib.openai_agents import StatefulMCPServer, StatelessMCPServer + from temporalio.contrib.openai_agents import ( + StatefulMCPServerProvider, + StatelessMCPServer, + ) class TrackingMCPServer(MCPServer): calls: list[str] @@ -2412,8 +2413,8 @@ async def get_prompt( raise NotImplementedError() tracking_server = TrackingMCPServer(name="HelloServer") - server: Union[StatefulMCPServer, StatelessMCPServer] = ( - StatefulMCPServer(tracking_server) + server: Union[StatefulMCPServerProvider, StatelessMCPServer] = ( + StatefulMCPServerProvider(lambda: tracking_server) if stateful else StatelessMCPServer(tracking_server) ) @@ -2490,10 +2491,10 @@ async def test_stateful_mcp_server_no_worker(client: Client): pytest.skip("Mcp not supported on Python 3.9") from agents.mcp import MCPServerStdio - from temporalio.contrib.openai_agents import StatefulMCPServer + from temporalio.contrib.openai_agents import StatefulMCPServerProvider - server = StatefulMCPServer( - MCPServerStdio( + server = StatefulMCPServerProvider( + lambda: MCPServerStdio( name="Filesystem-Server", params={ "command": "npx", From 5f7c6449f9e6574171b36e1abf63ebf1f48263bd Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Sep 2025 11:32:00 -0700 Subject: [PATCH 32/39] Trying to fix core commit --- temporalio/bridge/Cargo.lock | 218 ----------------------------------- temporalio/bridge/sdk-core | 2 +- 2 files changed, 1 insertion(+), 219 deletions(-) diff --git a/temporalio/bridge/Cargo.lock b/temporalio/bridge/Cargo.lock index 47b78dfb4..740f9a547 100644 --- a/temporalio/bridge/Cargo.lock +++ b/temporalio/bridge/Cargo.lock @@ -17,17 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -170,15 +159,6 @@ version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -233,22 +213,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "core-foundation" version = "0.10.1" @@ -265,15 +229,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -307,16 +262,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.20.11" @@ -366,21 +311,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "deflate64" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -444,17 +374,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - [[package]] name = "dirs" version = "6.0.0" @@ -725,16 +644,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.0.2" @@ -843,15 +752,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.3.1" @@ -1104,15 +1004,6 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "instant" version = "0.1.13" @@ -1211,26 +1102,6 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" -[[package]] -name = "liblzma" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libredox" version = "0.1.9" @@ -1406,12 +1277,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -1566,16 +1431,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.1" @@ -1663,18 +1518,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppmd-rust" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2416,17 +2259,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2814,25 +2646,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - [[package]] name = "tinystr" version = "0.8.1" @@ -3117,12 +2930,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - [[package]] name = "typetag" version = "0.2.20" @@ -3729,20 +3536,6 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -3783,23 +3576,12 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ - "aes", "arbitrary", "bzip2", - "constant_time_eq", "crc32fast", - "deflate64", "flate2", - "getrandom 0.3.3", - "hmac", "indexmap", - "liblzma", "memchr", - "pbkdf2", - "ppmd-rust", - "sha1", - "time", - "zeroize", "zopfli", "zstd", ] diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 4614dcb8f..4078190b9 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 4614dcb8f4ffd2cb244eb0a19d7485c896e3459e +Subproject commit 4078190b99ef16691512bc58e1da2d109419bc93 From 39e9d3181f6b933cd5446e96b7f6ade934204531 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Sep 2025 11:50:45 -0700 Subject: [PATCH 33/39] Wait for cancellation --- temporalio/contrib/openai_agents/_mcp.py | 14 +++++++++++++- tests/contrib/openai_agents/test_openai.py | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index 1c694a896..bf1adbb0b 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -17,7 +17,12 @@ TIMEOUT_TYPE_HEARTBEAT, TIMEOUT_TYPE_SCHEDULE_TO_START, ) -from temporalio.exceptions import ActivityError, ApplicationError +from temporalio.exceptions import ( + ActivityError, + ApplicationError, + CancelledError, + is_cancelled_exception, +) from temporalio.worker import PollerBehaviorSimpleMaximum, Worker from temporalio.workflow import ActivityConfig, ActivityHandle @@ -213,6 +218,13 @@ async def connect(self) -> None: async def cleanup(self) -> None: if self._connect_handle: self._connect_handle.cancel() + try: + await self._connect_handle + except Exception as e: + if is_cancelled_exception(e): + pass + else: + raise async def __aenter__(self): await self.connect() diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 95ace6242..a8136a127 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -12,6 +12,7 @@ Optional, Sequence, Union, + cast, no_type_check, ) @@ -2466,6 +2467,7 @@ async def get_prompt( "list_tools", "cleanup", ] + assert len(cast(StatefulMCPServerProvider, server)._servers) == 0 else: assert tracking_server.calls == [ "connect", From 975b2e1cae7e5ff75125ca5add9508cf4700826c Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 18 Sep 2025 12:45:39 -0700 Subject: [PATCH 34/39] update readme --- temporalio/contrib/openai_agents/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/temporalio/contrib/openai_agents/README.md b/temporalio/contrib/openai_agents/README.md index d28ceb7b5..c183d1911 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -403,14 +403,17 @@ As described in [Tool Calling](#tool-calling), context propagation is read-only ### MCP -Presently, MCP is supported only via `HostedMCPTool`, which uses the OpenAI Responses API and cloud MCP client behind it. -The OpenAI Agents SDK also supports MCP clients that run in application code, but this integration does not. +The MCP protocol is stateful, but many MCP servers are stateless. +We let you choose between two MCP wrappers, one designed for stateless MCP servers and one for stateful MCP servers. +These wrappers work with all transport varieties. + +Note that when using network-accessible MCP servers, you also can also use the tool `HostedMCPTool`, which is part of the OpenAI Responses API and uses an MCP client hosted by OpenAI. | MCP Class | Supported | |:-----------------------|:---------:| -| MCPServerStdio | No | -| MCPServerSse | No | -| MCPServerStreamableHttp| No | +| MCPServerStdio | Yes | +| MCPServerSse | Yes | +| MCPServerStreamableHttp| Yes | ### Guardrails From 2a999a208a37144d15ed0137fe37992295e59187 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Sep 2025 15:15:01 -0700 Subject: [PATCH 35/39] Change stateless to a provider model, add caching option --- temporalio/contrib/openai_agents/__init__.py | 4 +- temporalio/contrib/openai_agents/_mcp.py | 74 ++++++++----- .../openai_agents/_temporal_openai_agents.py | 8 +- temporalio/contrib/openai_agents/workflow.py | 18 +++- tests/contrib/openai_agents/test_openai.py | 101 +++++++++++------- 5 files changed, 136 insertions(+), 69 deletions(-) diff --git a/temporalio/contrib/openai_agents/__init__.py b/temporalio/contrib/openai_agents/__init__.py index c6478cb1f..4074d1ebd 100644 --- a/temporalio/contrib/openai_agents/__init__.py +++ b/temporalio/contrib/openai_agents/__init__.py @@ -12,7 +12,7 @@ try: from temporalio.contrib.openai_agents._mcp import ( StatefulMCPServerProvider, - StatelessMCPServer, + StatelessMCPServerProvider, ) except ImportError: pass @@ -36,7 +36,7 @@ "ModelActivityParameters", "OpenAIAgentsPlugin", "OpenAIPayloadConverter", - "StatelessMCPServer", + "StatelessMCPServerProvider", "StatefulMCPServerProvider", "TestModel", "TestModelProvider", diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index bf1adbb0b..fc13d8f49 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -30,11 +30,18 @@ class _StatelessMCPServerReference(MCPServer): - def __init__(self, server: str, config: Optional[ActivityConfig] = None): + def __init__( + self, + server: str, + config: Optional[ActivityConfig], + cache_tools_list: bool, + ): self._name = server + "-stateless" self._config = config or ActivityConfig( start_to_close_timeout=timedelta(minutes=1) ) + self._cache_tools_list = cache_tools_list + self._tools = None super().__init__() @property @@ -52,12 +59,17 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: - return await workflow.execute_activity( + if self._tools: + return self._tools + tools = await workflow.execute_activity( self.name + "-list-tools", args=[], result_type=list[MCPTool], **self._config, ) + if self._cache_tools_list: + self._tools = tools + return tools async def call_tool( self, tool_name: str, arguments: Optional[dict[str, Any]] @@ -88,10 +100,10 @@ async def get_prompt( ) -class StatelessMCPServer: +class StatelessMCPServerProvider: """A stateless MCP server implementation for Temporal workflows. - This class wraps an MCP server to make it stateless by executing each MCP operation + This class wraps a function to create MCP servers to make them stateless by executing each MCP operation as a separate Temporal activity. Each operation (list_tools, call_tool, etc.) will connect to the underlying server, execute the operation, and then clean up the connection. @@ -99,14 +111,15 @@ class StatelessMCPServer: function, this cannot be used. """ - def __init__(self, server: MCPServer): + def __init__(self, server_factory: Callable[[], MCPServer]): """Initialize the stateless temporal MCP server. Args: - server: An MCPServer instance + server_factory: A function which will produce MCPServer instances. It should return a new server each time + so that state is not shared between workflow runs """ - self._server = server - self._name = server.name + "-stateless" + self._server_factory = server_factory + self._name = server_factory().name + "-stateless" super().__init__() @property @@ -117,39 +130,43 @@ def name(self) -> str: def _get_activities(self) -> Sequence[Callable]: @activity.defn(name=self.name + "-list-tools") async def list_tools() -> list[MCPTool]: + server = self._server_factory() try: - await self._server.connect() - return await self._server.list_tools() + await server.connect() + return await server.list_tools() finally: - await self._server.cleanup() + await server.cleanup() @activity.defn(name=self.name + "-call-tool") async def call_tool( tool_name: str, arguments: Optional[dict[str, Any]] ) -> CallToolResult: + server = self._server_factory() try: - await self._server.connect() - return await self._server.call_tool(tool_name, arguments) + await server.connect() + return await server.call_tool(tool_name, arguments) finally: - await self._server.cleanup() + await server.cleanup() @activity.defn(name=self.name + "-list-prompts") async def list_prompts() -> ListPromptsResult: + server = self._server_factory() try: - await self._server.connect() - return await self._server.list_prompts() + await server.connect() + return await server.list_prompts() finally: - await self._server.cleanup() + await server.cleanup() @activity.defn(name=self.name + "-get-prompt") async def get_prompt( name: str, arguments: Optional[dict[str, Any]] ) -> GetPromptResult: + server = self._server_factory() try: - await self._server.connect() - return await self._server.get_prompt(name, arguments) + await server.connect() + return await server.get_prompt(name, arguments) finally: - await self._server.cleanup() + await server.cleanup() return list_tools, call_tool, list_prompts, get_prompt @@ -189,8 +206,9 @@ class _StatefulMCPServerReference(MCPServer, AbstractAsyncContextManager): def __init__( self, server: str, - config: Optional[ActivityConfig] = None, - server_session_config: Optional[ActivityConfig] = None, + config: Optional[ActivityConfig], + server_session_config: Optional[ActivityConfig], + cache_tools_list: bool, ): self._name = server + "-stateful" self._config = config or ActivityConfig( @@ -201,6 +219,8 @@ def __init__( start_to_close_timeout=timedelta(hours=1), ) self._connect_handle: Optional[ActivityHandle] = None + self._cache_tools_list = cache_tools_list + self._tools = None super().__init__() @property @@ -239,16 +259,22 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: + if self._tools: + return self._tools + if not self._connect_handle: raise ApplicationError( "Stateful MCP Server not connected. Call connect first." ) - return await workflow.execute_activity( + tools = await workflow.execute_activity( self.name + "-list-tools", args=[], result_type=list[MCPTool], **self._config, ) + if self._cache_tools_list: + self._tools = tools + return tools @_handle_worker_failure async def call_tool( @@ -361,7 +387,7 @@ async def heartbeat_every(delay: float, *details: Any) -> None: await asyncio.sleep(delay) activity.heartbeat(*details) - @activity.defn(name=self._name + "-server-session") + @activity.defn(name=self.name + "-server-session") async def connect() -> None: heartbeat_task = asyncio.create_task(heartbeat_every(30)) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 2d4b982ef..49b186d98 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -65,7 +65,7 @@ if typing.TYPE_CHECKING: from temporalio.contrib.openai_agents import ( StatefulMCPServerProvider, - StatelessMCPServer, + StatelessMCPServerProvider, ) @@ -204,7 +204,7 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): Example: >>> from temporalio.client import Client >>> from temporalio.worker import Worker - >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessMCPServer + >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessMCPServerProvider >>> from agents.mcp import MCPServerStdio >>> from datetime import timedelta >>> @@ -215,7 +215,7 @@ class OpenAIAgentsPlugin(temporalio.client.Plugin, temporalio.worker.Plugin): ... ) >>> >>> # Create MCP servers - >>> filesystem_server = StatelessMCPServer(MCPServerStdio( + >>> filesystem_server = StatelessMCPServerProvider(MCPServerStdio( ... name="Filesystem Server", ... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]} ... )) @@ -243,7 +243,7 @@ def __init__( model_params: Optional[ModelActivityParameters] = None, model_provider: Optional[ModelProvider] = None, mcp_servers: Sequence[ - Union["StatelessMCPServer", "StatefulMCPServerProvider"] + Union["StatelessMCPServerProvider", "StatefulMCPServerProvider"] ] = (), ) -> None: """Initialize the OpenAI agents plugin. diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index f266a3e53..43819c4e0 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -242,7 +242,9 @@ async def run_operation(ctx: RunContextWrapper[Any], input: str) -> Any: def stateless_mcp_server( - name: str, config: Optional[ActivityConfig] = None + name: str, + config: Optional[ActivityConfig] = None, + cache_tools_list: bool = False, ) -> "MCPServer": """A stateless MCP server implementation for Temporal workflows. @@ -256,18 +258,25 @@ def stateless_mcp_server( This approach is suitable for simple use cases where connection overhead is acceptable and you don't need to maintain state between operations. It should be preferred to stateful when possible due to its superior durability guarantees. + + Args: + name: A string name for the server. Should match that provided in the plugin. + config: Optional activity configuration for MCP operation activities. + Defaults to 1-minute start-to-close timeout. + cache_tools_list: If true, the list of tools will be cached for the duration of the server """ from temporalio.contrib.openai_agents._mcp import ( _StatelessMCPServerReference, ) - return _StatelessMCPServerReference(name, config) + return _StatelessMCPServerReference(name, config, cache_tools_list) def stateful_mcp_server( name: str, config: Optional[ActivityConfig] = None, server_session_config: Optional[ActivityConfig] = None, + cache_tools_list: bool = False, ) -> AbstractAsyncContextManager["MCPServer"]: """A stateful MCP server implementation for Temporal workflows. @@ -292,12 +301,15 @@ def stateful_mcp_server( Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. server_session_config: Optional activity configuration for the connection activity. Defaults to 1-hour start-to-close timeout. + cache_tools_list: If true, the list of tools will be cached for the duration of the server """ from temporalio.contrib.openai_agents._mcp import ( _StatefulMCPServerReference, ) - return _StatefulMCPServerReference(name, config, server_session_config) + return _StatefulMCPServerReference( + name, config, server_session_config, cache_tools_list + ) class ToolSerializationError(TemporalError): diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index a8136a127..d4203fc77 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2289,10 +2289,12 @@ async def test_output_type(client: Client): @workflow.defn class McpServerWorkflow: @workflow.run - async def run(self, timeout: timedelta) -> str: + async def run(self, timeout: timedelta, caching: bool) -> str: from agents.mcp import MCPServer - server: MCPServer = openai_agents.workflow.stateless_mcp_server("HelloServer") + server: MCPServer = openai_agents.workflow.stateless_mcp_server( + "HelloServer", cache_tools_list=caching + ) agent = Agent[str]( name="MCP ServerWorkflow", instructions="Use the tools to assist the customer.", @@ -2307,13 +2309,14 @@ async def run(self, timeout: timedelta) -> str: @workflow.defn class McpServerStatefulWorkflow: @workflow.run - async def run(self, timeout: timedelta) -> str: + async def run(self, timeout: timedelta, caching: bool) -> str: async with openai_agents.workflow.stateful_mcp_server( "HelloServer", config=ActivityConfig( schedule_to_start_timeout=timeout, start_to_close_timeout=timedelta(seconds=30), ), + cache_tools_list=caching, ) as server: agent = Agent[str]( name="MCP ServerWorkflow", @@ -2342,12 +2345,16 @@ class TrackingMCPModel(StaticTestModel): @pytest.mark.parametrize("use_local_model", [True, False]) @pytest.mark.parametrize("stateful", [True, False]) -async def test_mcp_server(client: Client, use_local_model: bool, stateful: bool): +@pytest.mark.parametrize("caching", [True, False]) +async def test_mcp_server( + client: Client, use_local_model: bool, stateful: bool, caching: bool +): if not use_local_model and not os.environ.get("OPENAI_API_KEY"): pytest.skip("No openai API key") if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") + from agents.mcp import MCPServer from mcp import GetPromptResult, ListPromptsResult # type: ignore from mcp import Tool as MCPTool # type: ignore @@ -2355,7 +2362,7 @@ async def test_mcp_server(client: Client, use_local_model: bool, stateful: bool) from temporalio.contrib.openai_agents import ( StatefulMCPServerProvider, - StatelessMCPServer, + StatelessMCPServerProvider, ) class TrackingMCPServer(MCPServer): @@ -2414,10 +2421,10 @@ async def get_prompt( raise NotImplementedError() tracking_server = TrackingMCPServer(name="HelloServer") - server: Union[StatefulMCPServerProvider, StatelessMCPServer] = ( + server: Union[StatefulMCPServerProvider, StatelessMCPServerProvider] = ( StatefulMCPServerProvider(lambda: tracking_server) if stateful - else StatelessMCPServer(tracking_server) + else StatelessMCPServerProvider(lambda: tracking_server) ) new_config = client.config() @@ -2440,7 +2447,7 @@ async def get_prompt( if stateful: result = await client.execute_workflow( McpServerStatefulWorkflow.run, - timedelta(seconds=30), + args=[timedelta(seconds=30), caching], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), @@ -2448,7 +2455,7 @@ async def get_prompt( else: result = await client.execute_workflow( McpServerWorkflow.run, - timedelta(seconds=30), + args=[timedelta(seconds=30), caching], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), @@ -2458,34 +2465,56 @@ async def get_prompt( if use_local_model: print(tracking_server.calls) if stateful: - assert tracking_server.calls == [ - "connect", - "list_tools", - "call_tool", - "list_tools", - "call_tool", - "list_tools", - "cleanup", - ] + if caching: + assert tracking_server.calls == [ + "connect", + "list_tools", + "call_tool", + "call_tool", + "cleanup", + ] + else: + assert tracking_server.calls == [ + "connect", + "list_tools", + "call_tool", + "list_tools", + "call_tool", + "list_tools", + "cleanup", + ] assert len(cast(StatefulMCPServerProvider, server)._servers) == 0 else: - assert tracking_server.calls == [ - "connect", - "list_tools", - "cleanup", - "connect", - "call_tool", - "cleanup", - "connect", - "list_tools", - "cleanup", - "connect", - "call_tool", - "cleanup", - "connect", - "list_tools", - "cleanup", - ] + if caching: + assert tracking_server.calls == [ + "connect", + "list_tools", + "cleanup", + "connect", + "call_tool", + "cleanup", + "connect", + "call_tool", + "cleanup", + ] + else: + assert tracking_server.calls == [ + "connect", + "list_tools", + "cleanup", + "connect", + "call_tool", + "cleanup", + "connect", + "list_tools", + "cleanup", + "connect", + "call_tool", + "cleanup", + "connect", + "list_tools", + "cleanup", + ] async def test_stateful_mcp_server_no_worker(client: Client): @@ -2537,7 +2566,7 @@ def override_get_activities() -> Sequence[Callable]: ) as worker: workflow_handle = await client.start_workflow( McpServerStatefulWorkflow.run, - timedelta(seconds=1), + args=[timedelta(seconds=1), False], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), From 58708c07443c1bdef08cb4c874942cf84ab9d5e6 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Sep 2025 15:23:11 -0700 Subject: [PATCH 36/39] Remove caching from stateful. Underlying server can handle it --- temporalio/contrib/openai_agents/_mcp.py | 11 +----- temporalio/contrib/openai_agents/workflow.py | 6 +-- tests/contrib/openai_agents/test_openai.py | 39 ++++++++------------ 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/temporalio/contrib/openai_agents/_mcp.py b/temporalio/contrib/openai_agents/_mcp.py index fc13d8f49..e41cc2d3a 100644 --- a/temporalio/contrib/openai_agents/_mcp.py +++ b/temporalio/contrib/openai_agents/_mcp.py @@ -208,7 +208,6 @@ def __init__( server: str, config: Optional[ActivityConfig], server_session_config: Optional[ActivityConfig], - cache_tools_list: bool, ): self._name = server + "-stateful" self._config = config or ActivityConfig( @@ -219,8 +218,6 @@ def __init__( start_to_close_timeout=timedelta(hours=1), ) self._connect_handle: Optional[ActivityHandle] = None - self._cache_tools_list = cache_tools_list - self._tools = None super().__init__() @property @@ -259,22 +256,16 @@ async def list_tools( run_context: Optional[RunContextWrapper[Any]] = None, agent: Optional[AgentBase] = None, ) -> list[MCPTool]: - if self._tools: - return self._tools - if not self._connect_handle: raise ApplicationError( "Stateful MCP Server not connected. Call connect first." ) - tools = await workflow.execute_activity( + return await workflow.execute_activity( self.name + "-list-tools", args=[], result_type=list[MCPTool], **self._config, ) - if self._cache_tools_list: - self._tools = tools - return tools @_handle_worker_failure async def call_tool( diff --git a/temporalio/contrib/openai_agents/workflow.py b/temporalio/contrib/openai_agents/workflow.py index 43819c4e0..63ec43154 100644 --- a/temporalio/contrib/openai_agents/workflow.py +++ b/temporalio/contrib/openai_agents/workflow.py @@ -276,7 +276,6 @@ def stateful_mcp_server( name: str, config: Optional[ActivityConfig] = None, server_session_config: Optional[ActivityConfig] = None, - cache_tools_list: bool = False, ) -> AbstractAsyncContextManager["MCPServer"]: """A stateful MCP server implementation for Temporal workflows. @@ -301,15 +300,12 @@ def stateful_mcp_server( Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts. server_session_config: Optional activity configuration for the connection activity. Defaults to 1-hour start-to-close timeout. - cache_tools_list: If true, the list of tools will be cached for the duration of the server """ from temporalio.contrib.openai_agents._mcp import ( _StatefulMCPServerReference, ) - return _StatefulMCPServerReference( - name, config, server_session_config, cache_tools_list - ) + return _StatefulMCPServerReference(name, config, server_session_config) class ToolSerializationError(TemporalError): diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index d4203fc77..fd24ecba6 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2289,7 +2289,7 @@ async def test_output_type(client: Client): @workflow.defn class McpServerWorkflow: @workflow.run - async def run(self, timeout: timedelta, caching: bool) -> str: + async def run(self, caching: bool) -> str: from agents.mcp import MCPServer server: MCPServer = openai_agents.workflow.stateless_mcp_server( @@ -2309,14 +2309,13 @@ async def run(self, timeout: timedelta, caching: bool) -> str: @workflow.defn class McpServerStatefulWorkflow: @workflow.run - async def run(self, timeout: timedelta, caching: bool) -> str: + async def run(self, timeout: timedelta) -> str: async with openai_agents.workflow.stateful_mcp_server( "HelloServer", config=ActivityConfig( schedule_to_start_timeout=timeout, start_to_close_timeout=timedelta(seconds=30), ), - cache_tools_list=caching, ) as server: agent = Agent[str]( name="MCP ServerWorkflow", @@ -2355,6 +2354,9 @@ async def test_mcp_server( if sys.version_info < (3, 10): pytest.skip("Mcp not supported on Python 3.9") + if stateful and caching: + pytest.skip("Caching is only supported for stateless MCP servers") + from agents.mcp import MCPServer from mcp import GetPromptResult, ListPromptsResult # type: ignore from mcp import Tool as MCPTool # type: ignore @@ -2447,7 +2449,7 @@ async def get_prompt( if stateful: result = await client.execute_workflow( McpServerStatefulWorkflow.run, - args=[timedelta(seconds=30), caching], + args=[timedelta(seconds=30)], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), @@ -2455,7 +2457,7 @@ async def get_prompt( else: result = await client.execute_workflow( McpServerWorkflow.run, - args=[timedelta(seconds=30), caching], + args=[caching], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30), @@ -2465,24 +2467,15 @@ async def get_prompt( if use_local_model: print(tracking_server.calls) if stateful: - if caching: - assert tracking_server.calls == [ - "connect", - "list_tools", - "call_tool", - "call_tool", - "cleanup", - ] - else: - assert tracking_server.calls == [ - "connect", - "list_tools", - "call_tool", - "list_tools", - "call_tool", - "list_tools", - "cleanup", - ] + assert tracking_server.calls == [ + "connect", + "list_tools", + "call_tool", + "list_tools", + "call_tool", + "list_tools", + "cleanup", + ] assert len(cast(StatefulMCPServerProvider, server)._servers) == 0 else: if caching: From 606f4e848db92c410baccc602fa6a41ee1c72aaa Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 18 Sep 2025 22:12:49 -0700 Subject: [PATCH 37/39] readme updates --- pyproject.toml | 2 +- temporalio/contrib/openai_agents/README.md | 105 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8fddea817..8f4044e2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temporalio" -version = "1.17.0" +version = "1.18.0" description = "Temporal.io Python SDK" authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }] requires-python = ">=3.9" diff --git a/temporalio/contrib/openai_agents/README.md b/temporalio/contrib/openai_agents/README.md index c183d1911..12c29bec5 100644 --- a/temporalio/contrib/openai_agents/README.md +++ b/temporalio/contrib/openai_agents/README.md @@ -351,6 +351,111 @@ Of course, code running in the workflow can invoke a Temporal activity at any ti Tools that run in the workflow can also update OpenAI Agents context, which is read-only for tools run as Temporal activities. +## MCP Support + +This integration provides support for Model Context Protocol (MCP) servers through two wrapper approaches designed to handle different implications of failures. + +While Temporal provides durable execution for your workflows, this durability does not extend to MCP servers, which operate independently of the workflow and must provide their own durability. The integration handles this by offering stateless and stateful wrappers that you can choose based on your MCP server's design. + +### Stateless vs Stateful MCP Servers + +You need to understand your MCP server's behavior to choose the correct wrapper: + +**Stateless MCP servers** treat each operation independently. For example, a weather server with a `get_weather(location)` tool is stateless because each call is self-contained and includes all necessary information. These servers can be safely restarted or reconnected to without changing their behavior. + +**Stateful MCP servers** maintain session state between calls. For example, a weather server that requires calling `set_location(location)` followed by `get_weather()` is stateful because it remembers the configured location and uses it for subsequent calls. If the session or the server is restarted, state crucial for operation is lost. Temporal identifies such failures and raises an `ApplicationError` to signal the need for application-level failure handling. + +### Usage Example (Stateless MCP) + +The code below gives an example of using a stateless MCP server. + +#### Worker Configuration + +```python +import asyncio +from datetime import timedelta +from agents.mcp import MCPServerStdio +from temporalio.client import Client +from temporalio.contrib.openai_agents import ( + ModelActivityParameters, + OpenAIAgentsPlugin, + StatelessMCPServerProvider, +) +from temporalio.worker import Worker + +async def main(): + # Create the MCP server provider + filesystem_server = StatelessMCPServerProvider( + lambda: MCPServerStdio( + name="FileSystemServer", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"], + }, + ) + ) + + # Register the MCP server with the OpenAI Agents plugin + client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=60) + ), + mcp_servers=[filesystem_server], + ), + ], + ) + + worker = Worker( + client, + task_queue="my-task-queue", + workflows=[FileSystemWorkflow], + ) + await worker.run() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +#### Workflow Implementation + +```python +from temporalio import workflow +from temporalio.contrib import openai_agents +from agents import Agent, Runner + +@workflow.defn +class FileSystemWorkflow: + @workflow.run + async def run(self, query: str) -> str: + # Reference the MCP server by name (matches name in worker configuration) + server = openai_agents.workflow.stateless_mcp_server("FileSystemServer") + + agent = Agent( + name="File Assistant", + instructions="Use the filesystem tools to read files and answer questions.", + mcp_servers=[server], + ) + + result = await Runner.run(agent, input=query) + return result.final_output +``` + +The `StatelessMCPServerProvider` takes a factory function that creates new MCP server instances. The server name used in `stateless_mcp_server()` must match the name configured in the MCP server instance. In this example, the name is `FileSystemServer`. + +### Stateful MCP Servers + +For implementation details and examples, see the [samples repository](https://github.com/temporalio/samples-python/tree/main/openai_agents/mcp). + +When using stateful servers, the dedicated worker maintaining the connection may fail due to network issues or server problems. When this happens, Temporal raises an `ApplicationError` and cannot automatically recover because it cannot restore the lost server state. +To recover from such failures, you need to implement your own application-level retry logic. + +### Hosted MCP Tool + +For network-accessible MCP servers, you can also use `HostedMCPTool` from the OpenAI Agents SDK, which uses an MCP client hosted by OpenAI. + ## Feature Support This integration is presently subject to certain limitations. From 6fc01c944b21a74a7a76b7f2e4edaf366e23cb00 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 19 Sep 2025 08:41:35 -0700 Subject: [PATCH 38/39] Revert pyproject update --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8f4044e2b..8fddea817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "temporalio" -version = "1.18.0" +version = "1.17.0" description = "Temporal.io Python SDK" authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }] requires-python = ">=3.9" From 53c3c39ab4e207a2d35560bbf12ca98a56da731f Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 19 Sep 2025 09:19:00 -0700 Subject: [PATCH 39/39] Fix no worker test --- tests/contrib/openai_agents/test_openai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index fd24ecba6..bcaeccfd9 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -2559,7 +2559,7 @@ def override_get_activities() -> Sequence[Callable]: ) as worker: workflow_handle = await client.start_workflow( McpServerStatefulWorkflow.run, - args=[timedelta(seconds=1), False], + args=[timedelta(seconds=1)], id=f"mcp-server-{uuid.uuid4()}", task_queue=worker.task_queue, execution_timeout=timedelta(seconds=30),