Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
28e58ac
fix(openai-agents): send MCP tool call _meta from the plugin's MCP se…
campoy Oct 27, 2026
e94b114
Address review findings
campoy Oct 29, 2026
5d55b69
Address review findings
campoy Oct 29, 2026
5894e85
Address review: stateful serverName, quieter warning, mixed fleets
campoy Oct 30, 2026
e8a3cef
Share the MCP handle options in one interface
campoy Oct 30, 2026
ed77bd8
Refuse a toolMetaResolver on the factory's server
campoy Oct 30, 2026
c69bccb
Address review findings
campoy Oct 30, 2026
bfc90d6
Address review findings
campoy Oct 30, 2026
c501190
Merge remote-tracking branch 'origin/main' into fix/openai-agents-typ…
campoy Oct 30, 2026
602f1d1
Refuse a stateful factory resolver in the operation Activities, as #2…
campoy Oct 30, 2026
aa51e5f
Export TOOL_META_RESOLVER_UNSUPPORTED_TYPE
campoy Oct 30, 2026
62599e6
README: upgrade order for a factory resolver, and the stateful handle…
campoy Oct 30, 2026
0af5470
Merge remote-tracking branch 'origin/main' into fix/openai-agents-typ…
campoy Oct 31, 2026
e100cfa
Address the third review: migration replay, export lists, History note
campoy Nov 2, 2026
8df1398
Merge remote-tracking branch 'origin/main' into fix/openai-agents-typ…
campoy Nov 2, 2026
6161ecf
Refuse a tool_meta_resolver on the Python factory's server too
campoy Nov 2, 2026
b2f2d76
Point each Python route's resolver refusal at a remedy it can reach
campoy Nov 2, 2026
ca75958
README: resolver after a RunState resume, and which Workers to upgrade
campoy Nov 2, 2026
0a36fee
README: the deprecated routes' resolver gets the suffixed server name
campoy Nov 3, 2026
2377901
Merge remote-tracking branch 'origin/main' into fix/openai-agents-typ…
campoy Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions python/openai_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,8 @@ worker-side OpenAI `MCPServer`. Configure workflow-facing behavior such as
openai-agents 0.22.3, tool input and output guardrails on
`temporal_mcp_server(...)`, where the OpenAI agent can use it. On the
worker-side server, a static `tool_filter` still filters the listed tools, a
callable `tool_filter` and a `require_approval` policy are rejected as described
below, and the other settings are ignored. Custom `MCPServer` method
callable `tool_filter`, a `require_approval` policy and a `tool_meta_resolver`
are rejected as described below, and the other settings are ignored. Custom `MCPServer` method
implementations still execute worker-side.

A callable `tool_filter` on the worker-side server is rejected, because the run
Expand All @@ -463,13 +463,22 @@ Agents SDK normalizes to no approval is allowed there: `"never"`, `False`, or a
mapping or tool list with only `"never"` entries. A callable policy is always
rejected, because it cannot be evaluated on the worker.

A `tool_meta_resolver` on the worker-side server is rejected too, because the
run context it receives exists only in the workflow. Pass it to
`temporal_mcp_server(tool_meta_resolver=...)` instead, where it runs in the
workflow and must be deterministic. Its result is recorded in workflow history,
so it must not hold secrets.

These checks run in every MCP Activity. A run that already holds its cached tool
list, the default, first meets them when it calls a tool: the agent gets the
error as the tool's output, and the run can complete without the tool. Let open
runs finish before you upgrade or add such a setting to the factory's server.
A resolver there never had an effect, so removing it from the factory's server
in the same deploy is enough.
Moving the policy into `temporal_mcp_server(require_approval=...)` is also a
workflow change: an open run that already called one of that server's tools
fails replay with a nondeterminism error.
fails replay with a nondeterminism error. Moving a resolver there is a workflow
change too, so guard it with `workflow.patched()` or let open runs finish first.

Every MCP operation is a Temporal Activity. The workflow-side tool list is
cached by default; pass `cache_tools_list=False` to refresh it on every Agents
Expand Down Expand Up @@ -540,10 +549,17 @@ The legacy stateful path retains its dedicated per-workflow worker and
persistent-session behavior. To require tool approval on these servers, pass
`require_approval` to `stateless_mcp_server()` or `stateful_mcp_server()`;
as with `temporal_mcp_server()`, a policy on the factory's server is rejected
unless it never asks for approval. So is a callable `tool_filter` there, in
every MCP operation, direct `call_tool()` and prompt calls included; a static
filter still works. Remove such a filter from the factory in the same deploy,
or let open runs finish first.
unless it never asks for approval. So are a callable `tool_filter` and a
`tool_meta_resolver` there, in every MCP operation, direct `call_tool()` and
prompt calls included; a static filter still works. On these routes, set a
resolver on the server that `stateless_mcp_server()` or `stateful_mcp_server()`
gives the workflow (`server.tool_meta_resolver = ...`); it then gets
`<name>-stateless` or `<name>-stateful` as `server_name`. As with
`temporal_mcp_server()`, it runs in the workflow and must be deterministic, its
result is recorded in workflow history, and adding it to a workflow with open
runs needs `workflow.patched()` or those runs to finish first. Remove such a
filter or resolver from the factory in the same deploy, or let open runs finish
first.

### Hosted MCP Tool

Expand Down
42 changes: 34 additions & 8 deletions python/openai_agents/src/temporalio/openai_agents/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
from temporalio.openai_agents._mcp_server_checks import (
_has_dynamic_tool_filter,
_has_worker_side_require_approval,
_has_worker_side_tool_meta_resolver,
_reject_dynamic_tool_filter,
_reject_worker_side_require_approval,
_reject_worker_side_tool_meta_resolver,
)
from temporalio.worker import PollerBehaviorSimpleMaximum, Worker
from temporalio.workflow import ActivityConfig, ActivityHandle
Expand All @@ -43,6 +45,15 @@
)


def _legacy_tool_meta_resolver_remedy(route: str) -> str:
"""What the deprecated routes' tool_meta_resolver error tells the user to do."""
return (
f"set tool_meta_resolver on the server {route}() gives the workflow, or "
"register the factory with OpenAIAgentsPlugin(mcp_servers=...) and pass "
"it to temporal_mcp_server(tool_meta_resolver=...)"
)


@dataclasses.dataclass
class _StatelessListToolsArguments:
factory_argument: Any | None
Expand Down Expand Up @@ -173,9 +184,9 @@ def __init__(
name: The name of the MCP 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. It may accept a single positional parameter, which
receives a ``factory_argument`` from the workflow. A callable ``tool_filter`` or a ``require_approval``
policy that can ask for approval on the returned server is rejected in every MCP operation; a static
filter is applied.
receives a ``factory_argument`` from the workflow. A callable ``tool_filter``, a ``require_approval``
policy that can ask for approval, or a ``tool_meta_resolver`` on the returned server is rejected in
every MCP operation; a static filter is applied.
"""
warnings.warn(
"StatelessMCPServerProvider is deprecated; use "
Expand Down Expand Up @@ -207,6 +218,11 @@ def _create_server(self, factory_argument: Any | None) -> MCPServer:
_reject_worker_side_require_approval(
self._server_name, server, "stateless_mcp_server"
)
_reject_worker_side_tool_meta_resolver(
self._server_name,
server,
_legacy_tool_meta_resolver_remedy("stateless_mcp_server"),
)
return server

@property
Expand Down Expand Up @@ -481,8 +497,9 @@ def __init__(
name: The name of the MCP 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. It receives an optional ``factory_argument`` from the
workflow. A callable ``tool_filter`` or a ``require_approval`` policy that can ask for approval on the
returned server is rejected in every MCP operation; a static filter is applied.
workflow. A callable ``tool_filter``, a ``require_approval`` policy that can ask for approval, or a
``tool_meta_resolver`` on the returned server is rejected in every MCP operation; a static filter is
applied.
"""
warnings.warn(
"StatefulMCPServerProvider is deprecated; use "
Expand Down Expand Up @@ -515,6 +532,11 @@ def _server() -> MCPServer:
_reject_worker_side_require_approval(
self._server_name, server, "stateful_mcp_server"
)
_reject_worker_side_tool_meta_resolver(
self._server_name,
server,
_legacy_tool_meta_resolver_remedy("stateful_mcp_server"),
)
return server

@activity.defn(name=self.name + "-list-tools")
Expand Down Expand Up @@ -556,13 +578,17 @@ async def connect(
"Cannot connect to an already running server. Use a distinct name if running multiple servers in one workflow."
)
server = self._server_factory(args.factory_argument if args else None)
# A server with a callable tool filter or a worker-side approval
# policy is never connected. The dedicated worker still runs, and
# A server with a callable tool filter, a worker-side approval
# policy or a tool_meta_resolver is never connected. The dedicated worker still runs, and
# each of its operations fails with the same error, so the workflow
# sees that error at its first MCP call whatever its
# schedule-to-start timeout.
has_filter = _has_dynamic_tool_filter(server)
rejected = has_filter or _has_worker_side_require_approval(server)
rejected = (
has_filter
or _has_worker_side_require_approval(server)
or _has_worker_side_tool_meta_resolver(server)
)
self._servers[server_id] = server
try:
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from temporalio.openai_agents._mcp_server_checks import (
_reject_dynamic_tool_filter,
_reject_worker_side_require_approval,
_reject_worker_side_tool_meta_resolver,
)

_MCPServerFactory = Callable[[], MCPServer] | Callable[[Any], MCPServer]
Expand Down Expand Up @@ -162,6 +163,7 @@ def create(argument: Any = _NOT_SUPPLIED) -> _OpenAIMCPServerBackend:
server = cast(MCPServer, invoke(argument))
_reject_dynamic_tool_filter(name, server)
_reject_worker_side_require_approval(name, server, "temporal_mcp_server")
_reject_worker_side_tool_meta_resolver(name, server)
return _OpenAIMCPServerBackend(server)

return create
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ def _has_worker_side_require_approval(server: MCPServer) -> bool:
return callable(policy) or bool(policy) or overridden


def _has_worker_side_tool_meta_resolver(server: MCPServer) -> bool:
"""Return whether the server sets a ``tool_meta_resolver``."""
return getattr(server, "tool_meta_resolver", None) is not None


def _reject_worker_side_tool_meta_resolver(
name: str,
server: MCPServer,
remedy: str = "pass it to temporal_mcp_server(tool_meta_resolver=...)",
) -> None:
"""Raise when the factory's server has a ``tool_meta_resolver``; see the message.

The resolver needs the run context, which exists only in the workflow, so the
worker cannot run it, and the agent never reads the factory's copy. ``remedy``
says where to set it instead.
"""
if _has_worker_side_tool_meta_resolver(server):
raise ApplicationError(
f"MCP server {name!r} sets tool_meta_resolver on its worker-side "
"MCPServer, which cannot run there because the run context it "
f"receives exists only in the workflow; {remedy}, and remove it from "
"the factory's server",
non_retryable=True,
)


def _reject_worker_side_require_approval(
name: str, server: MCPServer, route: str
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,19 +372,19 @@ def __init__(
mcp_server_providers: Legacy MCP server providers to register with the
worker. Deprecated; use ``mcp_servers`` for new MCP v2
integrations. A provider's server with a callable
``tool_filter`` or a ``require_approval`` policy that can ask for
approval is rejected in every MCP operation; a static filter is
applied.
``tool_filter``, a ``require_approval`` policy that can ask for
approval, or a ``tool_meta_resolver`` is rejected in every MCP
operation; a static filter is applied.
mcp_servers: Named OpenAI MCP server factories to register on the worker.
Each factory may declare at most one positional parameter, which
receives the workflow's ``factory_argument`` (``None`` when the
workflow supplied none); a factory declaring more raises here. A
parameterless factory's modern connection is cached between
Activities. A factory called with a workflow ``factory_argument``
always creates a fresh server for that Activity. A factory's server
with a callable ``tool_filter`` or a ``require_approval`` policy
that can ask for approval is rejected; pass those to
``workflow.temporal_mcp_server()``.
with a callable ``tool_filter``, a ``require_approval`` policy
that can ask for approval, or a ``tool_meta_resolver`` is
rejected; pass those to ``workflow.temporal_mcp_server()``.
mcp_connection_idle_timeout: How long an idle modern MCP connection
remains cached. Defaults to five minutes. None disables idle
eviction, and zero disables reuse.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,9 @@ def temporal_mcp_server(
of using the worker connection cache.
server_options: OpenAI Agents ``MCPServer`` options such as
``require_approval`` and ``failure_error_function``. Set them here:
on the factory's server, a callable ``tool_filter`` and a
``require_approval`` policy that can ask for approval are rejected.
on the factory's server, a callable ``tool_filter``, a
``require_approval`` policy that can ask for approval and a
``tool_meta_resolver`` are rejected.
"""
try:
from temporalio.mcp import TemporalMCPClient
Expand Down
66 changes: 66 additions & 0 deletions python/openai_agents/tests/test_mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ async def run(self) -> str:
return result.final_output


@workflow.defn
class MCPToolMetaWorkflow:
@workflow.run
async def run(self) -> str:
server = openai_agents.workflow.temporal_mcp_server(
"hello",
tool_meta_resolver=lambda context: {
"tenant": "t-7",
"tool": context.tool_name,
"server": context.server_name,
},
)
agent = Agent[str](
name="MCP workflow",
instructions="Use the tools.",
mcp_servers=[server],
)
result = await Runner.run(agent, "Say hello to Tom and Tim.")
return result.final_output


def model() -> TestModel:
return TestModel.returning_responses(
[
Expand Down Expand Up @@ -214,6 +235,39 @@ def factory(argument: Any) -> AgentsMCPServer:
assert arguments == [{"tenant": "acme"}] * 3


async def test_temporal_mcp_server_sends_tool_meta(client: Client) -> None:
metas: list[Any] = []
mcp_server = server()

class RecordingServer(InProcessMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
metas.append(meta)
return await super().call_tool(tool_name, arguments, meta)

async with AgentEnvironment(
model=model(),
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=30)
),
mcp_servers={"hello": lambda: RecordingServer(mcp_server)},
) as environment:
plugged_client = environment.applied_on_client(client)
async with new_worker(plugged_client, MCPToolMetaWorkflow) as worker:
result = await plugged_client.execute_workflow(
MCPToolMetaWorkflow.run,
id=f"mcp-v2-meta-{uuid4()}",
task_queue=worker.task_queue,
)

assert result == "Hi Tom and Tim!"
assert metas == [{"tenant": "t-7", "tool": "say_hello", "server": "hello"}] * 2


def create_backend(factory: Callable[[], AgentsMCPServer]) -> _OpenAIMCPServerBackend:
return cast(
Callable[[], _OpenAIMCPServerBackend],
Expand All @@ -233,6 +287,18 @@ def factory() -> AgentsMCPServer:
assert "temporal_mcp_server()" in str(err.value)


def test_worker_side_tool_meta_resolver_is_rejected() -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (tests): nothing committed exercises the path every refusal now points to: no test passes tool_meta_resolver to temporal_mcp_server() or checks the meta the factory's server receives. grep -rn tool_meta_resolver tests/ finds only the two refusal tests, and InProcessMCPServer.call_tool (lines 125-132) forwards meta with no assertion on it, so the body's 'already worked' rests on no test. A probe passes at this head: a subclass of InProcessMCPServer that appends meta in call_tool, a Workflow with temporal_mcp_server('hello', tool_meta_resolver=resolver) where resolver returns {'tenant': 't-7', 'tool': ctx.tool_name, 'server': ctx.server_name}, a model making one say_hello call, and assert metas == [{'tenant': 't-7', 'tool': 'say_hello', 'server': 'hello'}]. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added test_temporal_mcp_server_sends_tool_meta in b2f2d76. A RecordingServer records the meta the factory's server gets, the Workflow passes temporal_mcp_server("hello", tool_meta_resolver=...), and the test asserts {"tenant": "t-7", "tool": "say_hello", "server": "hello"} for both calls.

def factory() -> AgentsMCPServer:
value = InProcessMCPServer(server())
value.tool_meta_resolver = lambda context: {"tenant": "t-1"} # type: ignore[attr-defined]
return value

with pytest.raises(ApplicationError) as err:
create_backend(factory)
assert err.value.non_retryable
assert "temporal_mcp_server(tool_meta_resolver=...)" in str(err.value)


def test_static_worker_side_tool_filter_is_allowed() -> None:
def factory() -> AgentsMCPServer:
value = InProcessMCPServer(server())
Expand Down
12 changes: 10 additions & 2 deletions python/openai_agents/tests/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3080,7 +3080,8 @@ def factory(args: Any | None) -> MCPServer:


@pytest.mark.parametrize(
"setting", ["require_approval", "instance_evaluator", "tool_filter"]
"setting",
["require_approval", "instance_evaluator", "tool_filter", "tool_meta_resolver"],
)
@pytest.mark.parametrize("stateful", [True, False])
async def test_mcp_server_factory_settings_are_rejected(
Expand All @@ -3094,6 +3095,9 @@ async def test_mcp_server_factory_settings_are_rejected(
tracking_server._get_needs_approval_for_tool = ( # type: ignore[method-assign]
lambda tool, agent: True # type: ignore[reportUnknownLambdaType]
)
elif setting == "tool_meta_resolver":
tracking_server = get_tracking_server("HelloServer")
tracking_server.tool_meta_resolver = lambda context: {"tenant": "t-1"} # type: ignore[attr-defined]
else:
tracking_server = get_tracking_server("HelloServer")
tracking_server.tool_filter = lambda context, tool: True # type: ignore[attr-defined]
Expand Down Expand Up @@ -3138,7 +3142,11 @@ async def test_mcp_server_factory_settings_are_rejected(
assert isinstance(err.value.cause.cause, ApplicationError)
assert err.value.cause.cause.non_retryable
message = err.value.cause.cause.message
if setting != "tool_filter":
if setting == "tool_meta_resolver":
route = "stateful_mcp_server" if stateful else "stateless_mcp_server"
assert "'HelloServer' sets tool_meta_resolver" in message
assert f"on the server {route}() gives the workflow" in message
elif setting != "tool_filter":
route = "stateful_mcp_server" if stateful else "stateless_mcp_server"
assert "'HelloServer' sets require_approval" in message
assert f"{route}(require_approval=...)" in message
Expand Down
Loading
Loading