From 94982a5b93d3c1f5b6cf38dd72c5c10960c7c7db Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:16:02 +0000 Subject: [PATCH] feat: add MCP_HIDE_INPUT_IN_ERRORS env var to redact payloads from validation errors Pydantic's ValidationError repr includes the raw input_value by default. When the SDK calls model_validate_json() on untrusted data (SSE messages, OAuth responses) and validation fails, logger.exception() dumps the entire payload into logs. This can leak sensitive tool output or OAuth tokens. Setting MCP_HIDE_INPUT_IN_ERRORS=1 before importing the SDK applies hide_input_in_errors=True to the jsonrpc_message_adapter TypeAdapter and the OAuth models (OAuthToken, OAuthClientMetadata, OAuthMetadata, ProtectedResourceMetadata). The error type and location remain in the message; only the raw input is omitted. Opt-in via env var to preserve the current debugging-friendly default. --- src/mcp/shared/auth.py | 12 +++++++++- src/mcp/types/jsonrpc.py | 15 ++++++++++-- tests/test_types.py | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ca5b7b45a..66d5a6936 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,11 +1,15 @@ from typing import Any, Literal -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator + +from mcp.types.jsonrpc import HIDE_INPUT_IN_ERRORS class OAuthToken(BaseModel): """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1""" + model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS) + access_token: str token_type: Literal["Bearer"] = "Bearer" expires_in: int | None = None @@ -37,6 +41,8 @@ class OAuthClientMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ + model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS) + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) # supported auth methods for the token endpoint token_endpoint_auth_method: ( @@ -105,6 +111,8 @@ class OAuthMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ + model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS) + issuer: AnyHttpUrl authorization_endpoint: AnyHttpUrl token_endpoint: AnyHttpUrl @@ -134,6 +142,8 @@ class ProtectedResourceMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ + model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS) + resource: AnyHttpUrl authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) jwks_uri: AnyHttpUrl | None = None diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index 84304a37c..567db8f7d 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -2,9 +2,18 @@ from __future__ import annotations +import os from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +HIDE_INPUT_IN_ERRORS = os.environ.get("MCP_HIDE_INPUT_IN_ERRORS", "").lower() in ("1", "true") +"""When True, pydantic ValidationError reprs omit the ``input_value`` field. + +Set the ``MCP_HIDE_INPUT_IN_ERRORS`` environment variable to ``1`` or ``true`` before +importing the SDK to prevent raw request/response payloads from appearing in logs +when JSON parsing or validation fails. The error type and location are still shown. +""" RequestId = Annotated[int, Field(strict=True)] | str """The ID of a JSON-RPC request.""" @@ -80,4 +89,6 @@ class JSONRPCError(BaseModel): JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError -jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) +jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter( + JSONRPCMessage, config=ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS) +) diff --git a/tests/test_types.py b/tests/test_types.py index f424efdbf..f848939db 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,9 @@ +import subprocess +import sys from typing import Any import pytest +from pydantic import ValidationError from mcp.types import ( LATEST_PROTOCOL_VERSION, @@ -360,3 +363,51 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in tool.input_schema assert tool.input_schema["additionalProperties"] is False + + +def test_validation_error_shows_input_by_default(): + """By default, ValidationError repr includes input_value (pydantic's default behavior).""" + with pytest.raises(ValidationError) as exc_info: + jsonrpc_message_adapter.validate_json('{"result":{"content":"SECRET-PAYLOAD') + assert "input_value" in repr(exc_info.value) + assert "SECRET-PAYLOAD" in repr(exc_info.value) + + +_HIDE_INPUT_CHECK_SCRIPT = """ +import pydantic +from mcp.types import jsonrpc_message_adapter +from mcp.shared.auth import OAuthToken, OAuthMetadata, ProtectedResourceMetadata, OAuthClientMetadata + +def check(fn, name): + try: + fn() + except pydantic.ValidationError as e: + err = repr(e) + assert "input_value" not in err, f"{name}: input_value leaked: {err!r}" + assert "SECRET" not in err, f"{name}: payload leaked: {err!r}" + # still useful: error type/location present + assert "json_invalid" in err or "validation error" in err + else: + raise AssertionError(f"{name}: expected ValidationError") + +check(lambda: jsonrpc_message_adapter.validate_json('{"result":"SECRET'), "jsonrpc_message_adapter") +check(lambda: OAuthToken.model_validate_json('{"access_token":"SECRET'), "OAuthToken") +check(lambda: OAuthMetadata.model_validate_json('{"issuer":"SECRET'), "OAuthMetadata") +check(lambda: ProtectedResourceMetadata.model_validate_json('{"resource":"SECRET'), "ProtectedResourceMetadata") +check(lambda: OAuthClientMetadata.model_validate_json('{"redirect_uris":"SECRET'), "OAuthClientMetadata") +print("OK") +""" + + +@pytest.mark.parametrize("env_value", ["1", "true", "True", "TRUE"]) +def test_hide_input_in_errors_env_var(env_value: str): + """When MCP_HIDE_INPUT_IN_ERRORS is set, ValidationError repr omits input_value.""" + result = subprocess.run( + [sys.executable, "-c", _HIDE_INPUT_CHECK_SCRIPT], + env={"MCP_HIDE_INPUT_IN_ERRORS": env_value}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.stdout.strip() == "OK"