Skip to content

Bug: String parameters starting with digits coerced to numbers, causing UUID data loss #1873

Open

Description

@TommyVee

FastMCP GitHub Issue: Parameter Type Coercion Ignores str Type Annotations

Title

Parameter Type Coercion Ignores str Type Annotations, Causing Data Loss for UUIDs

Description

FastMCP is coercing string parameters that start with digits to int or float, ignoring explicit str type annotations. This causes permanent data loss for UUIDs and other string identifiers that begin with numeric characters.

Impact

  • Data Loss: Original UUID values cannot be recovered once coerced
  • Functionality Broken: Tools cannot process UUIDs starting with digits
  • Silent Corruption: Coercion happens before function code runs, making it hard to detect

Reproduction

Minimal Example

from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("TestServer")

@mcp.tool()
async def update_task(
    ctx: Context,
    task_id: str | None = None  # Explicitly typed as str
) -> str:
    """Update a task by UUID."""
    print(f"Received task_id: {task_id!r}, type: {type(task_id).__name__}")
    return f"Task ID: {task_id}"

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

Test Cases

Case 1: UUID Starting with Digits

  • Input: task_id = "58aa9efd-faad-4901-89e8-99e807a1a2d6"
  • Expected: Function receives task_id as str with full UUID
  • Actual: Function receives task_id as str with value "58" (truncated to first digits)

Case 2: UUID with Scientific Notation Pattern

  • Input: task_id = "3400e37e-b251-49d9-91b0-f8dd8602ff7e"
  • Expected: Function receives task_id as str with full UUID
  • Actual: Function receives task_id as str with value "3.4e+40" (scientific notation)

Actual Behavior

When calling the tool with a UUID starting with digits:

# Tool call
await update_task(task_id="58aa9efd-faad-4901-89e8-99e807a1a2d6")

# Function receives:
# task_id = "58"  (type: str, but value is corrupted)
# Original UUID is permanently lost

Evidence from Logs

[UUID DEBUG] manage_task CALLED: action=update, task_id=3.4e+40, task_id_type=str
[UUID DEBUG] manage_task ENTRY: task_id type=str, value=3.4e+40, isinstance(str)=True
[UUID DEBUG] Validation failed: Invalid UUID format for task_id: 3.4e+40. Received as type: str

Key observations:

  • Parameter arrives as str type (so type annotation is "respected" at the type level)
  • But the value is corrupted ("3.4e+40" instead of full UUID)
  • Original UUID is permanently lost

Root Cause Analysis

The Coercion Pipeline

  1. MCP Client sends UUID in JSON (as string or number)
  2. FastMCP JSON Parser processes the request
    • If JSON has number: parses as Python int/float
    • If JSON has string starting with digits: coerces to number
  3. FastMCP Parameter Processing
    • Converts coerced number back to string representation
    • 58 → "58" (original UUID 58aa9efd-... is lost)
    • 3.4e+40 → "3.4e+40" (original UUID 3400e37e-... is lost)
  4. Function receives corrupted string
    • Type is str (so type annotation appears respected)
    • But value is corrupted (original data is lost)

Why This Happens

FastMCP appears to:

  1. Parse JSON values and infer types based on content (not annotations)
  2. Coerce string values that look like numbers to numeric types
  3. Convert numeric types back to strings to match type annotations
  4. But the original string value is lost during coercion

Code Location

The coercion likely happens in:

  • mcp.server.fastmcp.utilities.func_metadata.FuncMetadata.pre_parse_json()
  • Or in FastMCP's parameter validation/coercion logic before call_fn_with_arg_validation()

Expected Behavior

When a parameter is annotated as str | None:

  1. FastMCP should preserve the string value as-is, regardless of whether it starts with digits
  2. No type coercion should occur based on value content
  3. Type annotations should be authoritative for parameter types

Correct Behavior Example

@mcp.tool()
async def update_task(ctx: Context, task_id: str | None = None) -> str:
    # If called with: task_id="58aa9efd-faad-4901-89e8-99e807a1a2d6"
    # Should receive: task_id="58aa9efd-faad-4901-89e8-99e807a1a2d6" (full UUID)
    # NOT: task_id="58" (truncated)
    pass

Impact Assessment

Severity: High

  • Data Loss: Permanent loss of UUID values
  • Functionality Broken: Tools cannot process many valid UUIDs
  • Silent Failure: Coercion happens before user code, making it hard to detect
  • Affects Many Use Cases: Any string identifier starting with digits

Affected UUIDs

Any UUID starting with digits will be corrupted:

  • 58aa9efd-... → "58"
  • 3400e37e-... → "3.4e+40"
  • 12345678-... → "12345678" (might work if all digits, but still wrong)
  • 0a1b2c3d-... → "0" (starts with zero)

Real-World Impact

In our production system:

  • ~15% of UUIDs start with digits (based on UUID v4 distribution)
  • Cannot update/delete tasks with these UUIDs via MCP tools
  • Workaround required: Use direct API calls instead of MCP tools

Suggested Fix

Option 1: Respect Type Annotations (Recommended)

FastMCP should use type annotations as the authoritative source for parameter types:

# In func_metadata.py or parameter processing
def process_parameter(value: Any, annotation: type) -> Any:
    # If annotation is str (or str | None), preserve as string
    if annotation is str or (hasattr(annotation, '__origin__') and str in getattr(annotation, '__args__', [])):
        return str(value) if value is not None else None
    # Otherwise, use existing type inference
    return infer_type(value)

Option 2: Disable Content-Based Coercion for Annotated Parameters

Only coerce types when type annotation is ambiguous (e.g., Any):

# Only coerce if annotation doesn't specify a type
if annotation is Any or annotation is None:
    # Use content-based type inference
    value = coerce_based_on_content(value)
else:
    # Respect the annotation
    value = coerce_to_annotation(value, annotation)

Option 3: Add Explicit Schema Support

Allow tools to specify explicit JSON schemas that override type inference:

@mcp.tool(
    input_schema={
        "type": "object",
        "properties": {
            "task_id": {"type": "string"}  # Explicit schema
        }
    }
)
async def update_task(ctx: Context, task_id: str | None = None) -> str:
    pass

Workarounds (Current)

We've implemented patches to detect the issue, but cannot recover lost data:

# Patch pre_parse_json to detect coercion
def patched_pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
    for key, value in data.items():
        if key.endswith('_id') and isinstance(value, (int, float)):
            # Can detect, but original UUID is already lost
            logger.error(f"UUID coercion detected: {key}={value}")
    return original_pre_parse_json(self, data)

Limitation: Once coerced, original UUID cannot be recovered.

Environment

  • FastMCP Version: mcp package (latest as of 2026-01-15)
  • Python Version: 3.12
  • Transport: streamable-http
  • MCP Client: Cursor IDE (but issue likely affects all clients)

Additional Context

Related Issues

This affects any use case where:

  • String identifiers start with digits (UUIDs, IDs, codes)
  • Type annotations specify str but FastMCP infers numeric types
  • Data integrity is critical (cannot recover lost values)

Test Code

Full test case demonstrating the issue:

from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("UUIDTest")

@mcp.tool()
async def test_uuid(
    ctx: Context,
    uuid: str  # Explicit str annotation
) -> dict:
    """Test UUID parameter handling."""
    return {
        "received": uuid,
        "type": type(uuid).__name__,
        "length": len(uuid),
        "is_valid_uuid": len(uuid) == 36 and uuid.count('-') == 4
    }

# Test cases:
# 1. uuid="58aa9efd-faad-4901-89e8-99e807a1a2d6" → Should return full UUID, but returns "58"
# 2. uuid="3400e37e-b251-49d9-91b0-f8dd8602ff7e" → Should return full UUID, but returns "3.4e+40"

Request

Please fix FastMCP to:

  1. Respect str type annotations - Don't coerce string parameters to numbers
  2. Preserve string values - Don't truncate or convert UUID strings
  3. Use type annotations as authoritative - Don't infer types from content when annotation exists

This is a data loss bug that affects production systems. A fix would be greatly appreciated.


Repository: https://github.com/modelcontextprotocol/python-sdk
Related Files:

  • mcp/server/fastmcp/utilities/func_metadata.py (likely)
  • Parameter processing/validation logic

Priority: High (data loss issue)

Activity

  1. added
    bugSomething isn't working
    P1Significant bug affecting many users, highly requested feature
    ready for workEnough information for someone to start working on
    on Feb 10, 2026
  2. adityuhkapoor commented on Feb 11, 2026

    @adityuhkapoor

    I have a fix for this, PR incoming

  3. added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    and removed
    P1Significant bug affecting many users, highly requested feature
    on Mar 6, 2026
  4. added a commit that references this issue on Apr 17, 2026
    40e6cbf
  5. Christian-Sidak commented on Apr 17, 2026

    @Christian-Sidak

    Opened a fix in #2462 -- adds a _should_pre_parse_json() helper that skips JSON parsing for unions containing only simple scalar types (str, int, float, bool, NoneType), preventing string values like UUIDs from being coerced to numbers.

  6. Oxygen56 commented on Jun 10, 2026

    @Oxygen56

    This one bites real users — coercing "58aa9efd-..." down to "58" (or "3.4e+40") silently corrupts UUIDs and other digit-leading string IDs, and the data is unrecoverable by the time the tool function runs.

    I noticed #2032 targets this exact area (pre_parse_json and how str-union annotations are handled) but hasn't had activity since early March (~3 months). @adityuhkapoor — are you still planning to push it forward? Happy to help review and test it if so.

    If it's stalled, I'd be glad to pick it up and open a fresh PR against the latest main: ensure explicit str (and str | None) annotations are never JSON-pre-parsed/coerced, with regression tests covering the digit-leading-UUID and scientific-notation cases from this report. Just let me know which you'd prefer so we don't duplicate effort.

  7. Oxygen56 commented on Jun 10, 2026

    @Oxygen56

    Cannot reproduce on latest main — UUID/string coercion appears already fixed

    I tried to reproduce this on the latest main and the data-corruption behavior no longer occurs: string arguments that start with digits (UUIDs, leading-zero IDs, etc.) are preserved as strings, and wire values of the wrong type are now rejected with a ValidationError instead of being silently coerced.

    Two things on current main are responsible:

    1. Scalar guard in FuncMetadata.pre_parse_json (src/mcp/server/mcpserver/utilities/func_metadata.py). When a string field value is json.loads-able, the result is only substituted if it is a container; scalar results (str | int | float) are skipped:

      if isinstance(pre_parsed, str | int | float):
          continue
      new_data[data_key] = pre_parsed
    2. pydantic v2 lax validation no longer coerces int -> str, so a mismatched wire type fails validation rather than corrupting the value.

    Repro attempt (end-to-end via FuncMetadata.call_fn_with_arg_validation)

    Tool signature used: def f(task_id: str | None) -> str.

    Input (wire value) Old (reported) behavior Latest main behavior
    "58aa9efd-1234-..." (UUID string) corrupted -> 58 preserved as full UUID string
    "58" (numeric string) corrupted -> 58 (int) preserved as "58" (str)
    58 (JSON int, wrong type for str) silently coerced -> "58" ValidationError (correctly rejected)

    Underlying json.loads checks that explain it:

    • json.loads("58aa9efd-...") -> JSONDecodeError (Extra data) -> value kept as-is
    • json.loads("58") -> 58, but caught by the scalar guard -> value kept as-is

    Environment

    • Branch: latest main
    • Python 3.13, editable install (pip install -e .), pydantic 2.13.4

    So from what I can tell the original corruption is no longer reproducible. It may be worth confirming and closing this issue (and possibly PR #2032, which targets the same area). Happy to provide the exact scripts or test cases if useful.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions