Originally published at webofmike.com on 2026-10-26. The demo repo and every command in it were run before publishing.
pydantic-ai shipped two security fixes on 2026-10-19. The one I reproduced, GHSA-xv4x-g8mx-ch56, is a two-flag problem: include_content=False does exactly what it says for prompts and tool calls, and a second, separate flag still wrote MCP server URLs, authorization tokens, and custom header values into an OpenTelemetry span in plaintext.
I built themsquared/pydantic-ai-mcp-telemetry-leak to see the exact span attribute that leaked, confirm the fix closes it, and confirm the thing include_content=False was actually supposed to cover still works.
What the advisory covers
pydantic-ai lets an agent reach an MCP server two ways. MCPToolset is pydantic-ai's own client: it connects to the server directly, and the URL and headers never leave your process. MCPServerTool is provider-native: you hand the server's URL, an authorization_token, and headers to the model provider (Anthropic, OpenAI Responses, xAI), and the provider connects to the MCP server on your agent's behalf. Anthropic's beta MCP connector and OpenAI Responses' remote MCP tool both work this way.
That provider-native config has to travel somewhere: it's part of the request pydantic-ai builds for the provider's API. pydantic-ai's own instrumentation layer records that request shape in an OpenTelemetry span, under the attribute model_request_parameters, so you can see what tools and settings a call used. Before 2.58.0, that attribute included the full MCPServerTool dataclass, authorization_token and headers included, regardless of include_content.
Reproducing it without a network call
The leak lives in pydantic-ai's instrumentation code (_instrumentation.py), which wraps every model provider identically. That means I didn't need a real Anthropic account, a real MCP server, or even network access to reproduce it: any Model subclass that declares it supports MCPServerTool exercises the same span-building path.
class StubModel(Model):
"""Smallest possible Model: returns a canned response, no network, no API key."""
async def request(self, messages, model_settings, model_request_parameters):
return ModelResponse(
parts=[TextPart(content="ok")],
usage=RequestUsage(input_tokens=1, output_tokens=1),
model_name="stub",
timestamp=datetime.now(timezone.utc),
)
@classmethod
def supported_native_tools(cls):
return frozenset({MCPServerTool})
Wrap it in InstrumentedModel with include_content=False, the setting someone reaches for specifically to keep prompts and tool arguments out of traces, and hand it a native MCP tool config carrying a fake token:
mcp_tool = MCPServerTool(
id="internal-billing-mcp",
url="https://internal-mcp.example.com/mcp",
authorization_token="sk-mcp-prod-SUPER-SECRET-TOKEN-abc123",
headers={"X-Tenant-Key": "tenant-9f8e-SECRET"},
)
settings = InstrumentationSettings(include_content=False)
model = InstrumentedModel(StubModel(), settings)
params = ModelRequestParameters(native_tools=[mcp_tool])
await model.request(messages, None, params)
Capture the span with an in-memory OpenTelemetry exporter and read model_request_parameters back off it.
What came back
Against pydantic-ai-slim==2.56.0, with include_content=False set on this exact span:
{"native_tools":[{"kind":"mcp_server","id":"internal-billing-mcp",
"url":"https://internal-mcp.example.com/mcp",
"authorization_token":"sk-mcp-prod-SUPER-SECRET-TOKEN-abc123",
"headers":{"X-Tenant-Key":"tenant-9f8e-SECRET"}}], ...}
The token and the custom header are both right there, unredacted, on a span whose whole point was to not carry sensitive content. Meanwhile, on the same span, gen_ai.input.messages correctly came back as structure only, [{"role":"user","parts":[{"type":"text"}]}], no actual prompt text. include_content=False worked exactly as documented for the thing it's documented to cover. It just doesn't cover model_request_parameters, which ships under its own flag, include_model_request_parameters, defaulting to True. Someone configuring include_content=False to keep secrets out of their tracing backend had no reason to know a second flag existed, let alone that it defaulted the other way.
Same script, same inputs, against pydantic-ai-slim==2.58.0:
{"native_tools":[{"kind":"mcp_server","id":"internal-billing-mcp",
"url":"[REDACTED]","authorization_token":"[REDACTED]",
"headers":{"X-Tenant-Key":"[REDACTED]"}}], ...}
The fix redacts the specific fields that carry credentials inside a native tool's config, independent of either flag. model_request_parameters still tells you a native MCP tool was in play; it no longer tells you what it would take to use it.
Why this is worse than a logging bug
Traces usually land somewhere with a different access model than your secret store: a shared observability platform, a third-party APM vendor, a teammate who has trace-viewer access but not secrets-manager access. None of those audiences is supposed to be able to read your MCP server's bearer token. Before 2.58.0, every traced call through a provider-native MCP connector handed it to all of them, in plaintext, in a system most people don't think of as a place secrets end up.
If you're on an affected version (pydantic-ai-slim before 2.58.0, or before 1.107.8 on the 1.x line): upgrade, restrict access to historical traces in the meantime, and rotate any credential that ever passed through a provider-native MCP tool config while instrumented. The fix is a point upgrade, not a config change, which is exactly why include_content=False alone wasn't going to save you.
The same release also patched GHSA-5pfm-2g3g-gh85: with non-empty server-side message_history and the default manage_system_prompt='server', Vercel AI and AG-UI adapters could execute an available tool with client-chosen arguments without an actual model request. I didn't reproduce that one; it needs a live UI adapter session rather than a single instrumented span, which is a different repo. It's the same release and the same lesson from a different angle: an agent framework's convenience defaults are worth re-reading every time they patch one.
agentevals' OTel-native scoring ships eval results through the same kind of pipeline this post is about: an agent's run reduced to spans in a trace backend. That's exactly the surface worth auditing for what else ends up in those spans besides the thing you meant to measure.
Try it
git clone https://github.com/themsquared/pydantic-ai-mcp-telemetry-leak.git
cd pydantic-ai-mcp-telemetry-leak
./scripts/demo.sh
One script, two throwaway virtualenvs, no API key. If you're instrumenting any agent framework with OpenTelemetry and relying on a single "don't log content" flag, this is worth fifteen minutes: check whether your framework ships a second flag for tool and connector configuration, and whether it defaults the way you'd assume.
Repo: themsquared/pydantic-ai-mcp-telemetry-leak.
Frequently asked questions
What is GHSA-xv4x-g8mx-ch56?
A Medium-severity advisory in pydantic-ai: a provider-native MCP connection's server URL, authorization token, and custom header values could land in plaintext in an OpenTelemetry request-span attribute, even with include_content=False set. It affects pydantic-ai-slim before 2.58.0 on the 2.x line and before 1.107.8 on the 1.x line, and is fixed in both.
Does include_content=False stop pydantic-ai from logging MCP credentials in OpenTelemetry traces?
Not before 2.58.0. include_content=False correctly withholds prompt and tool-call content, but a provider-native MCP tool's config is recorded in a separate model_request_parameters span attribute, governed by its own flag (include_model_request_parameters, default True). Setting include_content=False alone left that attribute, and any credentials in it, untouched.
What is provider-native MCP in pydantic-ai, and how is it different from MCPToolset?
MCPToolset is pydantic-ai's own client: it connects to an MCP server directly and the server's URL and headers never leave your process. A provider-native MCPServerTool instead hands the server's URL, an authorization_token, and headers to the model provider (Anthropic, OpenAI Responses, xAI), which connects to the MCP server itself. That config travels as part of the request pydantic-ai builds, which is what pydantic-ai's own instrumentation layer then recorded in the trace span.
How can I reproduce the pydantic-ai MCP telemetry leak myself?
themsquared/pydantic-ai-mcp-telemetry-leak runs one script against two virtualenvs, pydantic-ai-slim 2.56.0 and 2.58.0, with no network calls and no API key. A 15-line stub Model declares support for MCPServerTool and returns a canned response; an in-memory OpenTelemetry exporter captures the resulting span so you can diff the model_request_parameters attribute between versions directly.
Canonical version, with machine-readable markdown at https://webofmike.com/pydantic-ai-mcp-telemetry-leak/index.md: https://webofmike.com/pydantic-ai-mcp-telemetry-leak/
Top comments (0)