Skip to content

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

@TommyVee

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions