Skip to content

Breaking: reject a require_approval policy on the OpenAI Agents MCP factory's server - #275

Merged
campoy merged 7 commits into
mainfrom
fix/openai-agents-python-factory-require-approval-ignored
Oct 31, 2026
Merged

campoy merged 7 commits into
mainfrom
fix/openai-agents-python-factory-require-approval-ignored

Conversation

@campoy

@campoy campoy commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

The OpenAI Agents plugin now rejects a require_approval policy set on the MCP server that the Worker's factory returns, instead of silently ignoring it. The error is a non-retryable ApplicationError, like the existing one for a callable tool_filter there, and names the registered server and the option to use on that route:

MCP server 'bank-policy' sets require_approval on its worker-side MCPServer; the agent never reads that copy, so the policy takes effect only when passed to temporal_mcp_server(require_approval=...); remove it from the factory's server
  • The check runs where the plugin creates the factory's server, on all three routes: the backend factory for OpenAIAgentsPlugin(mcp_servers=...) and temporal_mcp_server() (next to the tool_filter check), StatelessMCPServerProvider (in every MCP Activity) and StatefulMCPServerProvider. On the stateful route, the session Activity checks the policy once, before it connects, and every operation on the dedicated Worker raises the error. A rejected server is never connected or cleaned up.
  • Only a policy the Agents SDK normalizes to no approval is allowed: "never", False, or a mapping or tool list with only "never" entries. Every other policy is rejected, including a mapping or tool list with an "always" entry that names a tool, and any callable, which cannot be evaluated on the worker. A server class or instance that replaces how the SDK evaluates the policy (_get_needs_approval_for_tool, including an evaluator assigned on the instance or a custom __getattribute__) is rejected the same way.
  • Both checks live in _mcp_server_checks.py, which needs no mcp extra. The approval check is a yes/no test plus a raising wrapper.
  • The stateful session now heartbeats through auto_heartbeater(interval=30) instead of its own task. auto_heartbeater takes an optional fixed interval; the model Activities keep the default.
  • The README and the docstrings of stateless_mcp_server(), stateful_mcp_server(), temporal_mcp_server() and OpenAIAgentsPlugin(mcp_servers=...) give the same rule. The README also lists the agent-facing settings that belong on temporal_mcp_server(), and says which are applied, rejected or ignored on the factory's server.

On the stateful route, the Workflow gets the error at its first MCP call, whatever its schedule_to_start_timeout. With async with stateful_mcp_server(...), the run then fails with an ActivityError caused by this error.

Breaking change: an app whose MCP factory returns a server with a require_approval policy that can ask for approval completes its runs today, and the tools run without approval unless the Workflow also sets a policy. With this change, the first MCP Activity for that server fails with the error above. This includes apps that also pass the policy in Workflow code but left a copy on the factory's server, for example in a factory shared with code outside Temporal.

There is no version gate. The check runs only in Activities, so recorded Histories replay as before. A run still open when its Worker is upgraded meets the check at its next MCP Activity for that server. A new run, or one that lists tools again, fails there. A run that already holds its cached tool list (the default on temporal_mcp_server()) first meets it when it calls a tool: the Agents SDK gives the agent the error as the tool's output, so the tool does not run but the run can complete without it.

Upgrade: remove the policy from the factory's server and pass it to temporal_mcp_server(require_approval=...), or to stateless_mcp_server() or stateful_mcp_server() (#220). A "never" policy can stay. Moving the policy into Workflow code makes the tools pause for approval, which changes what open runs do, so let runs that use a factory policy finish on the earlier version first, or keep them there with Worker Versioning.

Squash with this PR's title so the release notes keep Breaking:.

Why?

#220, for #219, added require_approval to stateless_mcp_server() and stateful_mcp_server(), so every route now has a Workflow-side option. A policy left on the factory's server was still silently ignored on all three routes, including temporal_mcp_server(): a tool meant to wait for approval ran at once, with no error or warning. #220 left this open ("Should a policy or resolver on the factory's server be rejected, like a callable tool_filter?"). This change rejects the policy, so the mistake is visible.

Checklist

  1. Closes [Bug] openai_agents: a require_approval policy on the MCP factory's server is silently ignored #274

  2. How was this tested:

    • test_mcp_v2.py: the backend factory must reject "always", a tool mapping, a tool list, a mapping and a tool list that mix "always" and "never", and a callable policy, and a server whose class, instance or __getattribute__ replaces the evaluator, with a non-retryable error that names temporal_mcp_server(require_approval=...); "never", False, a "never" mapping and a never-only tool list must still build the backend. The new factories use the module's InProcessMCPServer.
    • test_mcp_server_factory_require_approval_is_rejected in test_openai.py, stateless and stateful: a Workflow whose provider's factory sets require_approval="always", or installs an evaluator on the server instance, must fail with the error, naming 'HelloServer' and the route's option, within a 30-second execution timeout; the server must never be connected, cleaned up or called. The tracking server records a cleanup() call before it raises, so putting back the base's unguarded cleanup() fails this test (checked).
    • test_mcp_server has a new stateful-cleanup-error mode: cleanup() raises after a normal session, and the test checks the result, the call order, that the session is removed and that no heartbeat task is left. Every stateful mode, the approval one included, now checks that no heartbeat task is left. test_auto_heartbeater_interval_heartbeats_without_timeout checks that auto_heartbeater(interval=...) heartbeats with no heartbeat timeout, as the session needs.
    • Locally, with uv 0.12.24: make lint passes (ruff, pyright, mypy, basedpyright, pydocstyle), and the whole suite passes in one process (436 passed). Parallel make test runs on the loaded machine timed out in unrelated tests; CI is the full run.
  3. Any docs updates needed?
    The README's MCP section and the docstrings listed above are updated here.

Not covered:

The check reads the SDK's private _needs_approval_policy field, since the SDK has no public way to read the policy. Review found the field in every release from 0.14.0 through 0.24.0, and the tests fail if it is renamed within the pinned range.

…server

The agent reads the approval policy from the MCP server it holds in the
Workflow. A require_approval set on the server that the Worker's factory
returns is never read, so the tools ran without approval and nothing failed
or warned.

Reject such a policy where the plugin creates the factory's server, on all
three routes: OpenAIAgentsPlugin(mcp_servers=...) with temporal_mcp_server(),
StatelessMCPServerProvider and StatefulMCPServerProvider. The error is a
non-retryable ApplicationError, like the one for a callable tool_filter
there, and says to pass require_approval to temporal_mcp_server() or to the
deprecated helpers. A policy that never asks for approval, such as "never",
has the same effect as no policy and is allowed.

Breaking: runs whose factory sets such a policy completed before and now fail.

@DABH DABH left a comment

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.

Review at head 165f7c7 (merge base 69db660). Recipe: ten finder angles, 52 raw items deduplicated to 23 candidates, one adversarial verifier per candidate (most with end-to-end runs on the dev server across the v2, stateless and stateful routes), then a gap sweep that found nothing new. Verdicts: 16 confirmed, 2 plausible, 5 refuted and dropped (the stateful 'rejected mode' keeping the dedicated Worker alive is the right design, histories are event-for-event identical to healthy sessions; a cleanup() exception during cancellation keeps base precedence; rejected servers hold nothing releasable before connect(); the heartbeat task ordering changes nothing measurable; a startup probe of factories contradicts their documented contract). In a worktree at the head: make lint clean, make test 432 passed, conventions OK, Activity names byte-identical to base. Not verified: the body's reproduction exit codes and 4-of-4 replay run; the 'about 7 seconds' figure was not reproduced (the rejection path takes 50 to 300 ms).

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

Where the problems cluster: the check itself is correct for every SDK-normalized form and the rejection is the right call. One behavioural gap: a run that already holds its cached tool list meets the check at call-tool, where the Agents SDK turns the non-retryable failure into tool-error text and the run completes. Everything else is wording that misstates consequences, tests whose guards are vacuous or whose deadlines were already purged from this file once, and layering of the helper in the deprecated module.

PR body: 'Draft while the breaking change and this question are open.' is stale (not a draft); the open question about the private field is answered by the evidence above; 'a mapping or tool list with an "always" entry' over-generalizes (an 'always' entry with an empty tool_names list normalizes to {} and is allowed); the first commit's subject lacks the Breaking: prefix the title carries, and release notes derive from commit subjects, so the squash subject should keep the title; the upgrade guidance should add the cached-tool-list behaviour from the P2. Pre-existing, out of scope: the legacy routes lack the callable tool_filter guard the v2 route has; the except asyncio.CancelledError: pass around await heartbeat_task can swallow the Activity's own cancellation in a one-await window; the general session-start failure gap (factory or connect() failures surface only via schedule-to-start as a generic DedicatedWorkerFailure, or never); no v2 test covers workflow-side temporal_mcp_server(..., require_approval=...) although it works.

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)

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.

P2: when a run already holds its default-cached tool list, the first MCP Activity to meet the check is call-tool, and the Agents SDK converts the non-retryable rejection into model-visible tool-error text, so the run completes instead of failing. The body's 'the first MCP Activity fails ... so the run fails unless the Workflow catches it' and 'a run still open when its Worker is upgraded fails at its next MCP Activity' do not hold on that path. Reproduced end to end: a v2 run (cache_tools_list=True by default; TemporalMCPClient returns the cache without an Activity, python/mcp/src/temporalio/mcp/_workflow.py:97-98, rebuilt by replay after a restart) waiting on a signal when the Workers are upgraded, or when the factory's policy is added, without draining: activities [list-tools, model, call-tool, model, model, call-tool, model]; call-tool fails with the require_approval message (non_retryable=True); the SDK wraps it (agents/mcp/util.py:733-762, tool.py:653-667, default_tool_error_function); the model receives 'An error occurred while running the tool. Please try again. Error: ... Activity task failed' with no reason; the Workflow COMPLETES with an answer produced without the tool; the require_approval text exists only in the ActivityTaskFailed event. With failure_error_function=None the run fails, but as a retryable AgentsWorkflowError, because _reraise_workflow_failure (_openai_runner.py:469-477) inspects only the direct cause, the ActivityError, not its non-retryable ApplicationError. The stateful proxy has no cache; new runs fail loudly at list-tools; tools never run without approval. Fix the upgrade guidance in the body and README (drain open runs, or pass failure_error_function=None on the workflow-side server), and optionally make _reraise_workflow_failure classify through an ActivityError's cause.

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.

Thanks, confirmed: the tool still never runs, but a run that already holds its cached tool list can end without the error. The README and the PR body now say so: let open runs finish before upgrading, or pass failure_error_function=None to temporal_mcp_server() to fail such a run. _reraise_workflow_failure looking only at the direct cause is older and affects every non-retryable Activity error, so I filed it separately: #302.

if callable(policy) or policy:
raise ApplicationError(
f"MCP server {name!r} sets require_approval on its worker-side "
"MCPServer, where the agent never reads it, so its tools would run "

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: the message (and the helper docstring at 36-38 and README 449-451) asserts 'so its tools would run without approval', which is false whenever the workflow-side policy is also set. That belt-and-braces configuration (MCPServerStdio(..., require_approval="always") in the factory and temporal_mcp_server(name, require_approval="always") in the Workflow) enforced approval before this PR, because the agent reads the policy from the proxy it holds (agents/mcp/util.py:565, server.py:810-841) and all three proxies set it from the workflow-side argument (98, 355; _temporal_mcp_server.py:37); verified on all three routes with the check neutralized (zero tool executions, 'Approval needed'). At the head it is rejected with a message that misstates the consequence and recommends the remedy the user already applied. Realistic population: adopters of #220 who left the then-harmless copy on the factory, and factories shared with non-Temporal code (the body's own example). State the mechanism without asserting the run's approval behaviour: 'the agent never reads that copy, so the policy takes effect only when passed to temporal_mcp_server() ...; remove it from the factory's server'; shorten the docstring to point at the message; qualify the README sentence ('without a workflow-side policy its tools would run without approval').

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.

Agreed. The message now states the mechanism only: "MCP server 'X' sets require_approval on its worker-side MCPServer; the agent never reads that copy, so the policy takes effect only when passed to (require_approval=...); remove it from the factory's server". The docstring points to the message, and the README sentence now says "without a workflow-side policy".

"""
# The Agents SDK keeps the normalized policy only in this private field:
# a bool, a dict of tool names to bools, or a callable.
policy = getattr(server, "_needs_approval_policy", False)

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: the check keys only on the private _needs_approval_policy field, but the agent consumes approval through _get_needs_approval_for_tool, so a subclass overriding that method pauses under the plain SDK yet passes this check and runs its tools without approval; conversely any object whose attribute is auto-created and callable (a spec-less MagicMock or AsyncMock) is now rejected on the two legacy routes for a policy it never set. (a) class S(InProcessMCPServer) with _get_needs_approval_for_tool returning True in a plugin factory: the backend is built, the v2 Workflow returned the answer with the tool executed and no pause, while the plain SDK run reported interruptions; same on both legacy routes. The body scopes the check to the field, and overriding a private method is outside the documented API, so an accepted edge. (b) StatelessMCPServerProvider('x', lambda _: AsyncMock()) worked before the PR and a spec-less mock now fails every MCP Activity non-retryably; the v2 route already rejected spec-less mocks through the sibling tool_filter guard (_mcp_backend.py:161). Treat an overridden evaluator as unevaluable like the callable case (if type(server)._get_needs_approval_for_tool is not MCPServer._get_needs_approval_for_tool: raise; no SDK class overrides it in 0.22.0 to 0.22.3) and mention spec=MCPServer for mocks in the README.

P3 (optional robustness): getattr(server, "_needs_approval_policy", False) fails open: were the field absent, every factory policy would pass on all three routes with no warning until the next pin bump failed the tests. No plausible trigger: all 0.22.x releases carry the field, 0.23.1 and 0.24.0 keep it, the name has been stable since 0.14.0, and upstream has never patched a superseded minor across 124 releases; that evidence also answers the body's open question about reading the private field. If wanted, a cached one-time probe constructing MCPServerStdio(params={"command": "unused"}, require_approval="always") (microseconds, nothing spawned) that fails the Worker unless the field is True, keeping the lenient default for user subclasses.

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.

(a) Done: a subclass that overrides _get_needs_approval_for_tool now counts as a policy that can ask, like a callable, with a new test. (b) I kept mocks out of the README: a spec-less mock now fails the same way it already did on the v2 route, and a mock with spec=MCPServer passes both checks (checked). The startup probe: not added, given the field's history you found; I moved that evidence into the PR body as the answer to the open question.

async def cleanup(self):
# Like servers that create their exit stack in connect(), cleanup
# fails before the first connect.
if "connect" not in self.calls:

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): the new cleanup() guard in the tracking server raises before recording the call, so the stateful rejection test's 'never cleaned up' assertion (calls == [] at 3023) is vacuous; its comment also attributes connect-time exit stacks to servers that do not exist. Dropping if not rejected: around await server.cleanup() at _mcp.py:594 (the base shape) makes cleanup() run on the never-connected server and raise AttributeError into an already-closed Workflow, yet all seven assertions of the stateful case still pass (reproduced with a spy: cleanup called, calls == []); recording the call before raising makes 3023 fail on that regression. The stateless variant still catches it (3020-3021). Every openai-agents release from 0.3.2 to 0.22.3 creates the exit stack in __init__, and MCPServerStdio, Sse and StreamableHttp each returned normally from cleanup() twice before any connect(). Append 'cleanup' before raising (or assert on a flag) and reword the comment to its actual purpose: SDK servers tolerate cleanup() before connect(); raise here so a cleanup() call on a rejected server is detectable.

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.

Good catch. The tracking server now records cleanup before it raises, and the comment says what the guard is for. With the base shape put back (no if not rejected: around cleanup()), the stateful rejection test now fails on calls == [].

finally:
await server.cleanup()
heartbeat_task.cancel()
try:

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): the nested try/finally that guarantees the heartbeat task is cancelled even when server.cleanup() raises, a guarantee the body states, is exercised by no test. A private copy with 593-601 reverted to the base's flat form (guard kept) passes all 432 tests, while a stateful tracking server whose cleanup() raises after a normal connect() then leaks one forever-sleeping heartbeat_every task per session, exactly the base behaviour the PR fixes; at the head the same scratch shows zero leaked tasks. Give get_tracking_server a cleanup_error parameter (raise after appending 'cleanup') and add a stateful test running McpServerStatefulWorkflow.run(timedelta(seconds=30), None, None) with it, asserting the result, calls[-1] == 'cleanup', provider._servers == {} and the existing no-heartbeat-task scan after the Worker exits (passes at head in 0.44 s, fails on the flat-finally mutant).

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.

Covered, together with the heartbeat change below: the session now uses auto_heartbeater(interval=30), so the nested finally is gone. test_mcp_server has a new stateful-cleanup-error mode where cleanup() raises after a normal session; it checks the result, the call order, _servers == {} and that no _heartbeat_every task is left.

from temporalio.workflow import ActivityConfig, ActivityHandle


def _reject_worker_side_require_approval(name: str, server: MCPServer) -> 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 (plausible, scope): the fix rejects only require_approval while every other agent-facing setting on the factory's server (use_structured_content, an explicit failure_error_function, tool_meta_resolver, custom_data_extractor, and with openai-agents 0.22.3 inside the pin, server-level tool_input_guardrails and tool_output_guardrails) is still silently ignored on all three routes. The body's reason for excluding tool_meta_resolver ('setting it on the Workflow side works') applies verbatim to require_approval, so the real scoping rule (only safety-weakening settings are rejected) is unstated, and under that rule the 0.22.3 guardrails are a same-class safety setting the check does not cover: MCPServerStreamableHttp(..., tool_input_guardrails=[block_pii]) is accepted by create() and the agent builds tools from the proxy whose guardrails are None, so the tool receives the input with no guardrail and no warning. use_structured_content=True on the factory with a default workflow-side server delivers the text block instead of the structured JSON (verified). Pre-existing and not made worse here; README 440-443 already routes workflow-facing settings but omits three of these. Warn once per server name at the check sites for any non-default agent-facing setting (fields versus defaults; guardrails via getattr), or at minimum state the scoping rule and list use_structured_content; a single _validate_factory_server(name, server) covering tool_filter, approval and guardrails is the deeper form.

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.

Agreed that the rule was unstated. The README now lists the agent-facing settings that belong on temporal_mcp_server(), and says that on the factory's server a static filter still applies, a callable filter and an approval policy are rejected, and the rest are ignored. The guardrails are the same safety class, so I filed them separately: #303. A callable tool_filter on the legacy routes is #291, stacked on this PR.

# The dedicated worker still runs, and each of its operations fails
# with the policy error, so the workflow sees that error at its
# first MCP call whatever its schedule-to-start timeout.
try:

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 (cleanup, layering): on the stateful route the rejection is evaluated twice per session, first through try/except ApplicationError only to derive a boolean (the error is thrown away) and again on every op in _server() (511-514), although the SDK assigns the policy only in __init__; and the helper is homed in this deprecated module while its sibling _reject_dynamic_tool_filter lives in the extra-gated backend, which now imports it (_mcp_backend.py:29, the live route's first module-level dependency on the deprecated module). A future statement inside this try that raises ApplicationError is swallowed into rejected = True and surfaces at the first op as the require_approval message; when the deprecated providers go, _mcp_backend.py:29 breaks the v2 route's import until the helper is relocated. A single-evaluation provider reproduced every PR guarantee. Split into _has_worker_side_require_approval(server) -> bool plus the raising wrapper (keep the cheap per-op raise in _server(); never cache an exception instance), and put both checks in a small extra-free module (precedent: _heartbeat_decorator.py, _errors.py) without unifying them into one validator on the legacy routes without an explicit decision, since a callable tool_filter there fails retryably today.

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.

Done: both checks now live in _mcp_server_checks.py, which needs no mcp extra. The approval check is split into _has_worker_side_require_approval() and the raising wrapper; the session evaluates it once, and _server() keeps the per-operation raise. This PR doesn't run the tool_filter check on the legacy routes; #291 does that.

except ApplicationError:
rejected = True

heartbeat_task = asyncio.create_task(heartbeat_every(30))

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 (cleanup): the session Activity re-implements the plugin's auto_heartbeater lifecycle (heartbeat_every at 544-548 duplicates _heartbeat_decorator._heartbeat_every, and 596-601 duplicates its cancel-and-await line for line), and the PR had to hand-fix two leak classes in that copy that the decorator's try/finally prevents structurally. It is not a drop-in only because the decorator heartbeats solely when a heartbeat timeout is set while the session needs an unconditional 30 s cadence (the Workflow's cancel reaches the Activity only through heartbeat responses). A fixed-interval decorator applied to connect reproduced identical outcomes, call order and leak-freedom on the normal and rejected paths and the same cancel-delivery latency (29.42 s versus 29.78 s). auto_heartbeater(interval=30) creating _heartbeat_every(interval) unconditionally, applied under @activity.defn(name=self.name + "-server-session"); delete heartbeat_every, the manual task and the nested finally (keeping if not rejected: await server.cleanup()); point the scan at tests/test_openai.py:3029 to _heartbeat_every. Pre-existing: the hard-coded 30 s ignores a configured session heartbeat_timeout.

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.

Done: auto_heartbeater takes an optional interval, the session uses auto_heartbeater(interval=30), and the hand-written heartbeat task and nested finally are gone. The model Activities keep the default. The hard-coded 30 s is unchanged.

)
def test_worker_side_require_approval_is_rejected(require_approval: Any) -> None:
def factory() -> AgentsMCPServer:
return MCPServerStdio(

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): the two new v2 tests bypass the module's InProcessMCPServer fixture (used by the sibling tool_filter tests) with MCPServerStdio(params={"command": "unused"}, require_approval=...) and copy the backend-invocation cast twice more (five copies in the module), while the PR extended the other file's fixture exactly this way. Verified: an InProcessMCPServer subclass forwarding require_approval and **server_options yields identical normalized policies and byte-identical error messages for all six parametrized values; the pre-existing MCPServerStdio use at 332 legitimately needs the SDK class. Forward the options in InProcessMCPServer.__init__, build the new factories with it, and fold the cast into a create_backend(factory) helper.

P3 (tests, plausible): the never-allowed parametrization at 266 lists only "never" and {"say_hello": "never"} while the body documents False and never-only tool lists as allowed; the SDK normalizes those to the same values in 0.22.0 and 0.22.3, so no helper edit can distinguish them today, but extending the list to ["never", False, {"say_hello": "never"}, {"never": {"tool_names": ["say_hello"]}}] pins the documented promise.

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.

Done: InProcessMCPServer forwards server options, the new factories use it, and a create_backend() helper replaces the casts. The allowed list now also has False and a never-only tool list.

factory_argument: Any | None,
require_approval: Literal["always"] | None,
) -> str:
config = ActivityConfig(start_to_close_timeout=timedelta(seconds=30))

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): the conditional ActivityConfig mutation at 2688-2690 is unnecessary. A None schedule_to_start_timeout is indistinguishable from an absent key (typed timedelta | None; the SDK sets the proto field only when truthy; nothing in the plugin inspects config keys). The base's single expression with the widened annotation passes pyright, basedpyright and mypy, reproduces every assertion of the stateful test for None and the normal path for 30 s, and an outbound-interceptor capture shows identical StartActivityInput.schedule_to_start_timeout values. Keep timeout: timedelta | None at 2684 and pass the config inline as the base did.

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.

Done: back to the inline config, with timeout: timedelta | None.

- Move both factory-server checks to _mcp_server_checks.py, which needs
  no mcp extra; split the approval check into a test and a raising wrapper
  and evaluate it once per stateful session.
- Name the registered server and the route's option in the error, and
  state the mechanism without claiming the run's approval behavior.
- Reject a server that overrides how the SDK evaluates the policy.
- Heartbeat the stateful session with auto_heartbeater(interval=30).
- Give the same rule in the README and docstrings; list which settings
  on the factory's server are applied, rejected or ignored, and describe
  a run that already holds its cached tool list.
- Tests: record cleanup() before raising, add a cleanup-error session
  mode, use 30 s deadlines, inline the stateful ActivityConfig, and reuse
  InProcessMCPServer in the v2 tests.
Like _mcp_backend.py, the module's private functions are used only from other modules.
campoy added a commit that referenced this pull request Oct 30, 2026
Run the tool_filter check from #275's new _mcp_server_checks.py, with the registered server name, in the same places as the require_approval check.
campoy added a commit that referenced this pull request Oct 30, 2026
@campoy

campoy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Replies are in the threads; this round is e8f6e88.

  • PR body: updated. The stale draft line is gone, the private-field question is answered with your evidence, the "always" wording is fixed, and the upgrade notes cover the cached tool list.
  • Squash subject: agreed, squash with the PR title so the release notes keep Breaking:.
  • Pre-existing items: the legacy tool_filter guard is Breaking: reject a callable tool_filter on the deprecated OpenAI Agents MCP providers' servers #291, stacked on this PR. The heartbeat cancellation window, the session-start failure gap and a v2 test for a workflow-side require_approval are outside this PR.

@DABH DABH left a comment

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.

Second round, at head e8f6e88 (two commits over 165f7c7, merge base 69db660). In a worktree at the head: make lint is clean (ruff, pyright, mypy, basedpyright, pydocstyle); make test gave 432 passed and 4 failures in tracing, sandbox and tool-argument tests this PR does not touch, all of which pass when re-run in one process (load from parallel runs on this machine); the MCP tests pass serially, and CI is green on all four Python cells. Activity names are byte-identical to the base and the workflow.py changes are docstring-only. Verified by mutation: restoring the unguarded cleanup() on a rejected stateful server fails test_mcp_server_factory_require_approval_is_rejected (['cleanup'] == []), and moving auto_heartbeater's cancel out of its finally fails the stateful-cleanup-error mode and the stateful rejection case with a leaked _heartbeat_every task. In openai-agents 0.22.3 and 0.24.0 no SDK class overrides _get_needs_approval_for_tool, so the new override check rejects only user subclasses, and no 0.22.x release has followed 0.22.3. One new finding, verified by replay on the dev server. Dropped after checking: the error text for an overridden evaluator still says 'sets require_approval' (no known subclass overrides it), and the README's new list of workflow-side settings (the proxy forwards them to MCPServer.__init__, and openai-agents 0.22.3 reads tool guardrails from the server the agent holds). Not verified at runtime: the guardrail path, since the lock pins 0.22.0.

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

Previous rounds. Addressed: the cached-tool-list behaviour (P2) is now in the README and the body, with the _reraise_workflow_failure classification filed as #302; the message states the mechanism only and the README says 'without a workflow-side policy'; an overridden evaluator is rejected and tested; the tracking server records cleanup before raising, so the rejection test now catches the unguarded cleanup() (confirmed above); the nested finally is gone with auto_heartbeater(interval=30), and the new stateful-cleanup-error mode covers the leak (confirmed above); the deadlines are 30 s; every doc site gives one rule and the two v2 docstrings point to it; the message uses the registered name and leads with the route's own option; both checks live in _mcp_server_checks.py, and the session evaluates the policy once; the test fixture forwards server options, create_backend() replaces the casts and the allowed list includes False and a never-only tool list; ActivityConfig is inline again; the PR body items are fixed and the squash will keep the Breaking: title. Accepted declines: no README note on spec-less mocks (they already failed on the v2 route, and spec=MCPServer passes both checks), no startup probe for the private field (its history is now in the body), tool guardrails (#303) and the legacy callable tool_filter (#291) as follow-ups, and the pre-existing items left out of scope. Still open: nothing from round one. The one new item comes from the remedy I suggested last round.

Decision: comment rather than approve only because the README's new upgrade remedy can leave open runs stuck in a Workflow Task retry loop; with that sentence qualified, this is ready to approve.

Comment thread python/openai_agents/README.md Outdated
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. To fail
such a run instead, pass `failure_error_function=None` to

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.

P2: the remedy 'pass failure_error_function=None to temporal_mcp_server(...)' is itself a Workflow change that open runs replay, so a run whose History already holds a failed call-tool Activity for that server, including one that already met this rejection, fails replay with a nondeterminism error and gets stuck. I suggested this remedy last round without that caveat. Reproduced on the dev server with the plugin: a v2 Workflow using temporal_mcp_server("hello"), whose factory server's call_tool raises a non-retryable ApplicationError for one of two calls, completes with Hi Tom and Tim! and History [list-tools, model, call-tool (failed), model, call-tool, model]. Replaying that History with the unchanged Workflow passes. Replaying it with temporal_mcp_server("hello", failure_error_function=None) raises NondeterminismError: [TMPRL1100] Nondeterminism error: Fail workflow machine does not handle this event: HistoryEvent(id: 23, ActivityTaskScheduled); on a live Worker that is a Workflow Task retry loop. This paragraph targets runs like that: if the remedy ships after the upgrade, every open run that already met the rejection at call-tool and carried on has exactly this History. Earlier, unrelated failures count too: temporalio.mcp makes JSON-RPC errors such as INVALID_PARAMS and METHOD_NOT_FOUND non-retryable (_activities.py:48-57), and the default error function turned each into tool output. The setting also covers every tool failure on that server, so afterwards such errors fail the run instead of reaching the model as tool output it can recover from. Suggested: lead with 'let open runs finish before you upgrade or add such a setting to the factory's server', then qualify the alternative: 'Passing failure_error_function=None to temporal_mcp_server(...) makes such a run fail instead. It is a Workflow change: every tool failure on that server then fails the run, and an open run whose History already contains a failed call-tool Activity for that server fails replay with a nondeterminism error. Deploy it with the upgrade, and only where open runs have no earlier tool failure on that server.' The PR body's upgrade paragraph ('To make such a run fail instead ... pass failure_error_function=None') needs the same qualification.

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.

Thanks, agreed. Fixed in 6391dbf: the README now leads with letting open runs finish, and says that failure_error_function=None is a Workflow change. Every tool failure on that server then fails the run, and an open run whose History already has a failed call-tool Activity for that server fails replay, so deploy it with the upgrade, and only where open runs have no earlier tool failure there. The PR body's upgrade paragraph says the same.

The setting is a workflow change: an open run whose history already holds a failed call-tool Activity for that server fails replay, and every later tool failure fails the run. Lead with draining open runs.

@DABH DABH left a comment

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.

Third round, at head 6391dbf (one README commit over e8f6e88; merge base 69db660). In a worktree at the head, make lint is clean (ruff, pyright, mypy, basedpyright, pydocstyle) and make test gave 436 passed; a trial merge with current main (4bcde9a) is clean, lints clean and gives 437 passed; CI is green on all four Python cells. Replay, on the dev server: seven Histories recorded on main (v2, stateless and stateful routes, each with and without a factory-side policy, plus a workflow-side policy) replay on the head and on the merged tree, so 'recorded Histories replay as before' holds. Run live on the merged build, the three factory-policy cases fail at their first MCP Activity with the non-retryable message naming the registered server and the route's option, and the other four behave as on main. The findings below were verified by mutation (weakening the head's code under its tests, and running the head's tests against main's source) and by probes on the dev server. Checked and dropped: the check against 17 approval forms on the three SDK server classes (it matches the SDK's evaluator for every tool in 0.22.0, and 0.22.3 keeps the normalizer and evaluator unchanged); argument decoding through functools.wraps under @activity.defn (test_mcp_server_factory_argument[True] passes); a rejected v2 server entering the connection cache (it never does); an evaluator patched onto an instance rather than a subclass (outside any documented use); upgrade notes for the legacy routes (only the non-default stateless_mcp_server(cache_tools_list=True) keeps a cached list, and the legacy paragraph points back to the v2 rules); and a check that skips call-tool on the legacy routes (each legacy route checks in a helper that every operation goes through). TypeScript has nothing to align with: agents-core 0.14.3 takes an approval policy only on hosted MCP tools, not on local MCP servers. Not verified locally: Python 3.10 (CI covers it), and a stateful session carried across a real Worker restart.

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

Previous rounds. Addressed: last round's P2. The README now leads with letting open runs finish and calls failure_error_function=None a workflow change, with the replay condition and when to deploy it, and the PR body's upgrade paragraph says the same; the first comment below corrects one clause of the wording I suggested. Round-one items stand as recorded last round, since the code and tests are unchanged since e8f6e88. Accepted declines unchanged: spec-less mocks, the startup probe for the private field, #291, #302 and #303, and the pre-existing items left out of scope. Still open: nothing.

Where the problems cluster: the rejection is right on all three routes and replay is unaffected. What is left is the README's upgrade paragraph, which understates two workflow changes, and test coverage: the session's heartbeat and a mixed approval policy are untested, and the new cleanup-error mode also passes on main's source.

Decision: approve, because the check is correct on all three routes, recorded Histories replay, last round's blocking item is fixed and CI is green; the three items below are non-blocking documentation and test gaps.

Comment thread python/openai_agents/README.md Outdated
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.
Passing `failure_error_function=None` to `temporal_mcp_server(...)` makes such a
run fail instead. It is a workflow change: every tool failure on that server

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 (docs, non-blocking): the upgrade guidance still understates two workflow changes: moving the policy into temporal_mcp_server(require_approval=...), which the error message prescribes, also breaks replay for open runs, and failure_error_function=None does not fail the run on every tool failure. The second half is wording I suggested last round. (1) Recorded on main (4bcde9a) on the dev server: a Workflow on temporal_mcp_server("bank-policy") whose factory server sets require_approval="always" completes with [list-tools, model, call-tool, model]. Replaying it on the merged tree with require_approval="always" passed to temporal_mcp_server("bank-policy", ...) raises [TMPRL1100] Nondeterminism error: Complete workflow machine does not handle this event: HistoryEvent(id: 17, ActivityTaskScheduled). The PR body says this ('let runs that use a factory policy finish on the earlier version first, or keep them there with Worker Versioning'); the README, which outlives the body, only implies it. (2) With temporal_mcp_server("hello", failure_error_function=None), a scripted model that sends say_hello the truncated arguments {"name": gets two Workflow Task failures, Invalid JSON input for tool say_hello, until the probe's 12 s execution timeout, instead of a failed run: the SDK raises ModelBehaviorError, which has no Temporal cause, so _reraise_workflow_failure (_openai_runner.py:461-477) leaves it to fail the Workflow Task. The same model with the default handler completes. Replaying that default-handler History, which has no failed Activity, with failure_error_function=None also fails (Workflow activation completion failed: Invalid JSON input for tool say_hello), so 'a failed call-tool Activity' is narrower than the real condition. Suggested for 465-472: 'Let open runs finish before you upgrade or add such a setting to the factory's server. 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. Passing failure_error_function=None to temporal_mcp_server(...) makes a run that holds its cached tool list fail instead, but it is a workflow change too: a failed tool Activity on that server then fails the run, a malformed tool call from the model fails the workflow task until the setting is removed, and an open run with any earlier tool error on that server, including a malformed call that never reached an Activity, fails replay. Deploy it with the upgrade, and only where open runs have no earlier tool error on that server.' Dropping the failure_error_function alternative altogether would also be fine.

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.

Thanks. I went with dropping the alternative, in 0632489: the paragraph leads with letting open runs finish, and now adds that moving the policy into temporal_mcp_server(require_approval=...) is also a workflow change that breaks replay for open runs that already called that server's tools. The PR body drops it too.

return await _server().get_prompt(args.name, args.arguments)

@activity.defn(name=self.name + "-server-session")
@auto_heartbeater(interval=30)

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, non-blocking): nothing checks that the session heartbeats, which is how a Workflow's cancel, or its completion, reaches this Activity, and the PR moved that cadence onto a new keyword whose omission silently turns it off. With this line changed to the bare @auto_heartbeater that the model Activities use, the session never heartbeats (its default config sets no heartbeat timeout, 333-335), yet all 11 MCP and stateful tests in test_openai.py pass. In a probe on the dev server, the session's cleanup() runs 30.0 s after the run completes at the head; with the bare decorator the server is still connected 50 s after completion, and nothing ends the connection or the dedicated Worker before the session's start-to-close timeout (one hour by default) or Worker shutdown. Two related gaps in the new checks: the leak scans (tests/test_openai.py:2905-2909 and 3042-3046) match only the head's helper name, so the new stateful-cleanup-error mode passes against main's source, which leaks its heartbeat_every task when cleanup() raises (widening the scan to "heartbeat_every" in task.get_coro().__name__ fails on main with that pending task); and stateful-approval returns at 2891, before the _servers and heartbeat checks, so the body's 'Every stateful mode now checks the heartbeat task' does not hold for that mode. The heartbeat gap predates the PR (removing main's heartbeat task also passes main's tests). Suggested: a unit test of auto_heartbeater(interval=0.01) under temporalio.testing.ActivityEnvironment with an on_heartbeat recorder and no heartbeat timeout; a scan that matches "heartbeat" in the coroutine name, or compares asyncio.all_tasks() before and after; and the stateful checks moved above the approval return.

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 in 0632489. test_auto_heartbeater_interval_heartbeats_without_timeout runs auto_heartbeater(interval=0.01) under ActivityEnvironment with no heartbeat timeout and checks that it heartbeats, and that the bare decorator doesn't; a mutant that ignores interval fails it. The leak scans now match any task whose coroutine name contains heartbeat, and test_mcp_server runs the stateful checks before the approval mode returns. The test pins the decorator rather than the session's use of it; checking the session's 30 s cadence directly would need a 30 s test, so I left that out.

[
"always",
{"say_hello": "always"},
{"always": {"tool_names": ["say_hello"]}},

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, non-blocking): no case mixes "always" and "never", so the dict branch is pinned only by single-entry policies, where any and all agree. Changing any(policy.values()) to all(...) at _mcp_server_checks.py:53 passes all 13 approval tests in both files. Under that change a mixed policy such as {"transfer": "always", "lookup": "never"}, the usual way to gate one dangerous tool, passes the check and is silently ignored again, and an always list with no names is rejected. Add {"say_hello": "always", "other": "never"} and {"always": {"tool_names": ["say_hello"]}, "never": {"tool_names": ["other"]}} to this list.

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 both in 0632489. With any changed to all, those two cases now fail.

- README: lead with letting open runs finish, drop the
  failure_error_function=None alternative, and say that moving the
  policy into temporal_mcp_server() also breaks replay for open runs.
- Test that auto_heartbeater(interval=...) heartbeats without a
  heartbeat timeout, scan for any heartbeat task, run the stateful
  checks before the approval mode returns, and add mixed always/never
  policies to the rejected list.
campoy added a commit that referenced this pull request Oct 31, 2026
The SDK calls server._get_needs_approval_for_tool(), so an evaluator a
constructor assigns on the instance replaces the SDK's, but the check
looked only at the class. Resolve it with inspect.getattr_static, which
finds an instance attribute before the class's method without running
it, and count a custom __getattribute__ as an override too.
@campoy

campoy commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

One more change after your approval, in f163287: the check now also rejects an approval evaluator assigned on the server instance (self._get_needs_approval_for_tool = ...) or resolved by a custom __getattribute__, using inspect.getattr_static. You dropped this case as outside documented use; I closed it anyway, because with such a server the tool runs without approval on all three routes. test_worker_side_approval_evaluator_override_is_rejected and the rejection test have new cases for it, and they fail with the class-only lookup. Plain servers and spec=MCPServer mocks still pass. The PR body describes the wider rule.

campoy added a commit that referenced this pull request Oct 31, 2026
The rejection test keeps #291's setting parameter, with an instance_evaluator case from #275.
@campoy
campoy merged commit aad7a9d into main Oct 31, 2026
27 checks passed
@campoy
campoy deleted the fix/openai-agents-python-factory-require-approval-ignored branch October 31, 2026 08:49
campoy added a commit that referenced this pull request Oct 31, 2026
Run the tool_filter check from #275's new _mcp_server_checks.py, with the registered server name, in the same places as the require_approval check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] openai_agents: a require_approval policy on the MCP factory's server is silently ignored

2 participants