Skip to content

Support tool input/output guardrails for local MCP server tools #4620

Closed

Description

@dgenio

Please read this first

  • Have you read the docs? Yes.
  • Have you searched for related issues? Yes.

Related but distinct:

This request is narrower: allow locally executed MCP tools to opt into the existing tool input/output guardrail abstraction that is already available for function tools.

Describe the feature

The SDK has a useful tool_input_guardrails / tool_output_guardrails abstraction for function tools.

Today, however, the guardrails documentation explicitly scopes tool guardrails to function tools, while local MCP servers have a different set of controls:

  • tool_filter for model-visible tool availability;
  • require_approval for HITL approval;
  • tool_meta_resolver for per-call MCP _meta;
  • failure_error_function for MCP failure handling.

All of those are useful, but they solve different problems.

There does not appear to be a public configuration point for saying:

For every invocation of this locally executed MCP tool, run these input guardrails immediately before execution and these output guardrails immediately after execution.

This is slightly surprising because local MCP tools are already converted internally into SDK FunctionTool objects by MCPUtil.

Production-shaped example

Consider an agent connected to an application-managed MCP server.

Before an MCP call executes, the application may need to deterministically check things such as:

  • whether the model-generated arguments contain data that must not be sent to that tool;
  • whether an argument violates an invariant derived from trusted run context;
  • whether that particular invocation should be rejected even though the tool itself is visible and available.

After the MCP call returns, the application may need to check the result before it becomes model-visible, for example:

  • reject a response containing sensitive/internal data;
  • prevent an unexpected or unsafe result from entering the agent context;
  • apply the same output-validation policy already used for local function tools.

Conceptually:

model chooses MCP tool
        ↓
tool input guardrails
        ↓
local MCP server.call_tool(...)
        ↓
tool output guardrails
        ↓
result becomes model-visible

We ended up implementing this kind of wrapper downstream for MCP-backed agents. What started as a sample-specific concern became shared infrastructure because the same need recurred across tools and agents.

Why the existing MCP controls are not equivalent

tool_filter is primarily about visibility / eligibility: should the model see this tool?

require_approval answers whether execution should pause for approval.

tool_meta_resolver is useful for trusted per-call metadata such as tenant or trace information.

Tool guardrails answer a separate question:

Given the exact invocation/output at this execution boundary, should it be accepted?

These mechanisms are complementary rather than alternatives.

This also means I am not proposing that client-side guardrails replace authorization in the MCP server. Resource/action authorization should still be enforced at the protected operation.

Possible API shape

The exact API is less important than parity with the existing tool-guardrail model.

One possible server-level shape would be:

server = MCPServerStreamableHttp(
    name="Billing",
    params={"url": "..."},
    tool_input_guardrails=[validate_mcp_input],
    tool_output_guardrails=[validate_mcp_output],
)

A resolver/per-tool configuration could also make sense if maintainers prefer not to apply every guardrail to every tool.

Since local MCP tools are already converted to FunctionTools internally, another possibility may simply be attaching the configured guardrails during MCP → FunctionTool conversion rather than introducing a separate execution system.

Desired semantics

Ideally:

  1. local MCP tools can opt into the existing input-tool-guardrail semantics before call_tool();
  2. local MCP tools can opt into the existing output-tool-guardrail semantics before the result is accepted/model-visible;
  3. configuration can distinguish tools when necessary, rather than requiring one policy for every tool on a server;
  4. guardrails receive normal run context plus enough MCP origin information to distinguish server/tool identity;
  5. behavior is unchanged when no MCP guardrails are configured;
  6. interaction with require_approval is documented and, where practical, follows the same ordering semantics as function-tool approvals + guardrails;
  7. guardrail results remain observable/traced in the same way as existing tool-guardrail results.

Non-goals

This issue is intentionally not asking for:

  • a new authorization framework;
  • replacing server-side MCP authorization;
  • a generic middleware architecture for every SDK tool type;
  • arbitrary input/output transformation semantics beyond what existing tool guardrails support;
  • HostedMCPTool parity in the same change.

Hosted MCP has a materially different execution boundary because the Python process does not own the MCP round trip, so I think it should be considered separately.

Why this seems like a useful SDK-level primitive

MCP tools are often precisely where an agent crosses from model reasoning into external systems.

Having the same lightweight pre/post execution checks for:

FunctionTool

and:

local MCP-backed FunctionTool

would make it easier to apply one production policy consistently without wrapping every MCP tool manually or reimplementing the MCP integration layer.

If maintainers agree this belongs in the SDK, I'd be happy to help with a focused implementation or docs/tests around the preferred API shape.

Activity

  1. darklordVirtual commented on Aug 24, 2026

    @darklordVirtual

    I think parity here would be useful. One ordering property seems important enough to make explicit in the contract:

    the call a human approves should be the call that crosses the execution boundary.

    For a consequential MCP tool, I would expect something close to:

    resolve exact call → input guardrails → approval if required → execute → output guardrails → model context

    Running deterministic input checks before approval avoids asking a human to approve something the application already knows it must reject.

    More importantly, once approval is obtained, I would bind it to the same normalized tool identity and arguments that are passed to call_tool(). Any material change after approval should require a new decision rather than inheriting the old approval.

    That gives require_approval and input guardrails complementary semantics:

    input guardrail: is this invocation admissible under deterministic application constraints?

    approval: did the required authority approve this exact admissible invocation?

    server authorization: does the protected operation permit it under current resource authority?

    I would also preserve those as separate trace events. Otherwise an incident record that says “guardrail passed + approved” can become ambiguous about which argument set each decision actually covered.

    The output side has a similarly useful distinction: an output guardrail can prevent unsafe data from entering model context, but it should not be interpreted as verification that the external side effect itself was correct.

    Would it make sense for the MCP-to-FunctionTool conversion to preserve a stable per-call identity through guardrails, approval and execution, rather than having each layer observe the call independently?

  2. dgenio commented on Aug 24, 2026

    @dgenio
    Author

    I agree with the invariant that an approval must not silently carry over to a materially different invocation, but I would keep the ordering semantics aligned with the SDK's existing function-tool behavior rather than make input guardrails → approval → execution mandatory.

    Today, for function tools, the default is:

    approval if required → input guardrails → execution → output guardrails
    

    and ToolExecutionConfig(pre_approval_tool_input_guardrails=True) optionally adds a pre-approval guardrail pass. Importantly, calls that pass that pre-approval check are still checked again after approval, immediately before execution. That seems useful because the post-approval check can revalidate time-sensitive application constraints.

    So for MCP parity I would preserve that model:

    [optional pre-approval input guardrails]
    → approval if required
    → input guardrails again immediately before call_tool()
    → execute
    → output guardrails
    → model context
    

    I do agree with the identity/binding concern in a narrower sense: whatever invocation the approval decision refers to should not be mutated into materially different tool arguments and then inherit the old approval. The SDK already scopes normal approvals to a tool call ID, so I would first preserve those existing semantics through MCP → FunctionTool conversion rather than introduce a new general call-identity abstraction in this issue.

    That keeps the responsibilities distinct:

    • input guardrail: deterministic application checks at the execution boundary;
    • approval: authority approves/rejects the relevant invocation;
    • server authorization: protected operation makes its own current resource/action decision;
    • output guardrail: controls whether the returned data is accepted into agent/model context, not whether the external side effect was correct.

    If MCP guardrails reuse the existing FunctionTool machinery, matching these ordering and revalidation semantics would be the behavior I'd expect.

  3. added this to the 0.22.x milestone on Aug 24, 2026
  4. darklordVirtual commented on Sep 16, 2026

    @darklordVirtual

    That makes sense. I had the ordering too narrowly specified.

    Reusing the existing FunctionTool semantics is cleaner, especially since the post-approval input guardrail already provides the fresh check immediately before execution.

    The invariant I was trying to preserve is narrower: an approval for one invocation must not authorize materially different tool arguments later in the path.

    If the existing tool call ID already provides that binding through MCP conversion, I agree that preserving it is preferable to adding another identity abstraction here.

    The optional pre-approval pass also handles the wasted-approval case without changing the default ordering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions