Skip to content

Commit 94982a5

Browse files
committed
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.
1 parent 7ba4fb8 commit 94982a5

File tree

3 files changed

+75
-3
lines changed

3 files changed

+75
-3
lines changed

src/mcp/shared/auth.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
from typing import Any, Literal
22

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
3+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
4+
5+
from mcp.types.jsonrpc import HIDE_INPUT_IN_ERRORS
46

57

68
class OAuthToken(BaseModel):
79
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1"""
810

11+
model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS)
12+
913
access_token: str
1014
token_type: Literal["Bearer"] = "Bearer"
1115
expires_in: int | None = None
@@ -37,6 +41,8 @@ class OAuthClientMetadata(BaseModel):
3741
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
3842
"""
3943

44+
model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS)
45+
4046
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
4147
# supported auth methods for the token endpoint
4248
token_endpoint_auth_method: (
@@ -105,6 +111,8 @@ class OAuthMetadata(BaseModel):
105111
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
106112
"""
107113

114+
model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS)
115+
108116
issuer: AnyHttpUrl
109117
authorization_endpoint: AnyHttpUrl
110118
token_endpoint: AnyHttpUrl
@@ -134,6 +142,8 @@ class ProtectedResourceMetadata(BaseModel):
134142
See https://datatracker.ietf.org/doc/html/rfc9728#section-2
135143
"""
136144

145+
model_config = ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS)
146+
137147
resource: AnyHttpUrl
138148
authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1)
139149
jwks_uri: AnyHttpUrl | None = None

src/mcp/types/jsonrpc.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22

33
from __future__ import annotations
44

5+
import os
56
from typing import Annotated, Any, Literal
67

7-
from pydantic import BaseModel, Field, TypeAdapter
8+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
9+
10+
HIDE_INPUT_IN_ERRORS = os.environ.get("MCP_HIDE_INPUT_IN_ERRORS", "").lower() in ("1", "true")
11+
"""When True, pydantic ValidationError reprs omit the ``input_value`` field.
12+
13+
Set the ``MCP_HIDE_INPUT_IN_ERRORS`` environment variable to ``1`` or ``true`` before
14+
importing the SDK to prevent raw request/response payloads from appearing in logs
15+
when JSON parsing or validation fails. The error type and location are still shown.
16+
"""
817

918
RequestId = Annotated[int, Field(strict=True)] | str
1019
"""The ID of a JSON-RPC request."""
@@ -80,4 +89,6 @@ class JSONRPCError(BaseModel):
8089

8190

8291
JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
83-
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)
92+
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(
93+
JSONRPCMessage, config=ConfigDict(hide_input_in_errors=HIDE_INPUT_IN_ERRORS)
94+
)

tests/test_types.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import subprocess
2+
import sys
13
from typing import Any
24

35
import pytest
6+
from pydantic import ValidationError
47

58
from mcp.types import (
69
LATEST_PROTOCOL_VERSION,
@@ -360,3 +363,51 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields():
360363
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
361364
assert "$defs" in tool.input_schema
362365
assert tool.input_schema["additionalProperties"] is False
366+
367+
368+
def test_validation_error_shows_input_by_default():
369+
"""By default, ValidationError repr includes input_value (pydantic's default behavior)."""
370+
with pytest.raises(ValidationError) as exc_info:
371+
jsonrpc_message_adapter.validate_json('{"result":{"content":"SECRET-PAYLOAD')
372+
assert "input_value" in repr(exc_info.value)
373+
assert "SECRET-PAYLOAD" in repr(exc_info.value)
374+
375+
376+
_HIDE_INPUT_CHECK_SCRIPT = """
377+
import pydantic
378+
from mcp.types import jsonrpc_message_adapter
379+
from mcp.shared.auth import OAuthToken, OAuthMetadata, ProtectedResourceMetadata, OAuthClientMetadata
380+
381+
def check(fn, name):
382+
try:
383+
fn()
384+
except pydantic.ValidationError as e:
385+
err = repr(e)
386+
assert "input_value" not in err, f"{name}: input_value leaked: {err!r}"
387+
assert "SECRET" not in err, f"{name}: payload leaked: {err!r}"
388+
# still useful: error type/location present
389+
assert "json_invalid" in err or "validation error" in err
390+
else:
391+
raise AssertionError(f"{name}: expected ValidationError")
392+
393+
check(lambda: jsonrpc_message_adapter.validate_json('{"result":"SECRET'), "jsonrpc_message_adapter")
394+
check(lambda: OAuthToken.model_validate_json('{"access_token":"SECRET'), "OAuthToken")
395+
check(lambda: OAuthMetadata.model_validate_json('{"issuer":"SECRET'), "OAuthMetadata")
396+
check(lambda: ProtectedResourceMetadata.model_validate_json('{"resource":"SECRET'), "ProtectedResourceMetadata")
397+
check(lambda: OAuthClientMetadata.model_validate_json('{"redirect_uris":"SECRET'), "OAuthClientMetadata")
398+
print("OK")
399+
"""
400+
401+
402+
@pytest.mark.parametrize("env_value", ["1", "true", "True", "TRUE"])
403+
def test_hide_input_in_errors_env_var(env_value: str):
404+
"""When MCP_HIDE_INPUT_IN_ERRORS is set, ValidationError repr omits input_value."""
405+
result = subprocess.run(
406+
[sys.executable, "-c", _HIDE_INPUT_CHECK_SCRIPT],
407+
env={"MCP_HIDE_INPUT_IN_ERRORS": env_value},
408+
capture_output=True,
409+
text=True,
410+
check=False,
411+
)
412+
assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
413+
assert result.stdout.strip() == "OK"

0 commit comments

Comments
 (0)