Skip to content

Python: Preserve bounded MCP Host payload metadata - #8128

Draft
Eduard van Valkenburg (eavanvalkenburg) wants to merge 1 commit into
mainfrom
mcp-host-payload-core
Draft

Python: Preserve bounded MCP Host payload metadata#8128
Eduard van Valkenburg (eavanvalkenburg) wants to merge 1 commit into
mainfrom
mcp-host-payload-core

Conversation

@eavanvalkenburg

Copy link
Copy Markdown
Member

Motivation & Context

Generated MCP FunctionTool calls need to retain the complete CallToolResult for Host transports even when the model-facing parser intentionally projects only a bounded summary. Without a core-owned capture contract, fields such as structuredContent are unavailable to downstream transports and persisted history after tool execution.

This is layer 1 (the bottom layer) of the split replacement for #7971. It establishes only the core MCP Host-payload capture contract and remains orthogonal to #7897: it does not change which MCP content the model sees, or its ordering, preference, or deduplication.

Description & Review Guide

  • What are the major changes? Generated MCP functions now scope Host-payload capture with a private ContextVar, attach one JSON-safe complete result under a private core marker after built-in or custom parsing, preserve _meta on every model-facing item, and carry the marker through generic function-error conversion. Normal, long-running fallback, completed-task, and MCP error paths share the contract. A configurable per-result size cap rejects oversized payloads before full-copy materialization and retains an exact final serialization guard; None disables the cap. The option is keyword-only on MCPTool after all existing positional parameters and is forwarded by every concrete transport.
  • What is the impact of these changes? Host transports can consume complete MCP result metadata without changing direct call_tool() custom-parser return shapes, model-visible content, transport subclass behavior, or header-provider behavior. Oversized Host payloads are omitted while bounded model projections and _meta remain intact.
  • What do you want reviewers to focus on? Please focus on the generated-call-only scoping, exactly-once marker behavior, early size-bound enforcement, constructor compatibility, and preservation across task/error paths.

Related Issue

Part of #7959.

This draft is the core-only first layer replacing the broader open source PR #7971. Draft #7897 addresses model-facing MCP content selection and is intentionally independent from this Host-only metadata contract.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-code-quality

Copy link
Copy Markdown

Code Coverage Overview

Languages: Python

Python / code-coverage/python

The overall line coverage in commit 5aebc2d in the mcp-host-payload-cor... branch is 91%. Line coverage data for the main branch is not yet available.

Show a line coverage summary of the most covered files.
File main mcp-host-payload-cor... 5aebc2d +/-
packages/core/a...work/_skills.py 95%
packages/core/a...ework/_tools.py 94%
packages/core/a...rk/_sessions.py 94%
packages/core/a...ework/_types.py 93%
packages/core/a...bservability.py 93%
packages/core/a.../_compaction.py 93%
packages/openai..._chat_client.py 92%
packages/core/a...amework/_mcp.py 91%
packages/ag-ui/...i/_agent_run.py 91%
packages/foundr...g/_responses.py 87%

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Oversized errors lose metadata, custom function parsers discard markers, and error capture is not limited to generated calls.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds bounded, Host-only MCP result metadata capture while preserving model-facing output.

Changes:

  • Adds scoped, size-limited Host payload markers.
  • Preserves markers through task and error paths.
  • Adds payload, transport, and compatibility tests.
File summaries
File Description
python/packages/core/agent_framework/_mcp.py Implements MCP Host payload capture and propagation.
python/packages/core/agent_framework/_tools.py Transfers exception metadata into function results.
python/packages/core/tests/core/test_mcp.py Adds MCP payload regression coverage.
Review details

Suppressed comments (2)

python/packages/core/agent_framework/_mcp.py:2619

  • The legacy/fallback error path also captures a complete Host payload for direct call_tool_as_task() calls even though preserve_host_payload is false. Apply the generated-call guard here as well so fallback behavior remains scoped consistently.
                    _mcp_tool_result_host_payload(
                        fallback_result,
                        max_size_bytes=self.max_host_payload_size_bytes,
                    ),

python/packages/core/agent_framework/_mcp.py:2836

  • Completed-task errors likewise serialize and retain the Host payload when the public direct task API requested no preservation. Gate this capture on preserve_host_payload to keep all task paths generated-call-only.
                    _mcp_tool_result_host_payload(
                        payload,
                        max_size_bytes=self.max_host_payload_size_bytes,
                    ),
  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +314 to +317
if host_payload is not None:
self._function_result_additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] = host_payload
if isinstance(meta := host_payload.get("_meta"), Mapping):
self._function_result_additional_properties["_meta"] = dict(cast(Mapping[str, Any], meta))
Comment on lines +2442 to +2445
_mcp_tool_result_host_payload(
result,
max_size_bytes=self.max_host_payload_size_bytes,
),
Comment on lines +2449 to +2453
_with_mcp_tool_result_host_payload(
parsed,
result,
max_size_bytes=self.max_host_payload_size_bytes,
)
Comment on lines +675 to +678
with pytest.raises(_MCPToolResultException) as exc_info:
await _call_generated_mcp_tool(tool, "widget")

function_result = _function_execution_error_result(

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 5aebc2d700f4
Model: gpt-5.6-sol-fast

Overview

The PR cleanly scopes successful Host-payload capture to generated MCP functions, uses early and final serialization-size guards, and covers normal, error, fallback, and completed-task paths with targeted tests. The residual risks are that the per-result cap is not an aggregate bound, raw _meta can bypass it, oversized errors lose metadata, required-task dispatch bypasses public overrides, and an empty custom parser result changes the model-facing projection.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
5 verified findings remained after source verification (5 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_mcp.py

def __init__(self, message: str, host_payload: dict[str, Any] | None) -> None:
super().__init__(message)
self._function_result_additional_properties: dict[str, Any] = {}
if host_payload is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When an isError result exceeds the configured payload limit, _mcp_tool_result_host_payload() returns None, so this branch drops _meta along with the optional Host marker. Oversized generated errors therefore lose server metadata that the success path deliberately preserves for downstream consumers. Please carry a bounded copy of _meta independently of whether the complete Host payload fits.

updated_item = copy(item)
updated_item.additional_properties = dict(updated_item.additional_properties)
updated_item.additional_properties.pop(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY, None)
if meta is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When _meta itself makes the complete result exceed max_host_payload_size_bytes, the Host marker is omitted but this unbounded server-controlled metadata is still copied onto every parsed item and can be persisted in response history. This lets a large _meta bypass the retention bound even with a bounded custom parser. Please bound _meta independently while continuing to preserve metadata that fits.

dumped = model_dump(by_alias=True, exclude_none=True)
if not isinstance(dumped, Mapping):
return None
host_payload = cast(dict[str, Any], make_json_safe(dict(cast(Mapping[str, Any], dumped))))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This per-result copy can retain nearly 1 MiB for every generated MCP call, while the function loop launches the entire model-provided batch concurrently and only records max_function_calls after that batch completes. A sufficiently large single turn can therefore multiply the nominal cap into request-level memory exhaustion. Please enforce a pre-execution batch/concurrency bound or charge retained payloads against an aggregate request budget.

# route through the long-running task lifecycle transparently.
if self._tool_task_support_by_name.get(tool_name) == "required":
return await self.call_tool_as_task(tool_name, **kwargs)
return await self._call_tool_as_task(tool_name, kwargs, preserve_host_payload=preserve_host_payload)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For tools advertising required task support, this now bypasses the public call_tool_as_task() method that call_tool() previously dispatched through. Existing MCPTool subclasses that override that lifecycle method for authorization, telemetry, retries, or transport-specific handling will silently stop seeing transparent required-task calls. Please preserve dispatch through the public override while propagating the generated-call capture scope.

) -> list[Content]:
"""Attach the complete MCP result once without changing the model projection."""
items = [Content.from_text(parsed)] if isinstance(parsed, str) else list(parsed)
if not items:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A custom parse_tool_results callback may intentionally return an empty list[Content]; this converts that valid projection into model-facing text "[]", whereas the function-result layer previously represented it as empty output. Please preserve the parser's empty projection and carry the Host payload through a non-model-visible carrier instead.

Comment on lines +302 to +303
if index == 0 and host_payload is not None:
updated_item.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] = host_payload

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we keep the Host marker outside middleware-rewritable model items, or explicitly transfer it when an item is hidden? SecureMCPToolProxy labels MCP output as untrusted, and the default LabelTrackingFunctionMiddleware(auto_hide_untrusted=True) replaces this first item with a new variable-reference Content; _hide_item() stores only item.text, so _mcp_tool_result_host_payload is gone before Content.from_function_result() runs. As a result, security-enabled MCP Apps still lose structuredContent, isError, and _meta on the Host/history path this contract is meant to preserve.

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

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants