python-sdk icon indicating copy to clipboard operation
python-sdk copied to clipboard

`tools/call` with string `arguments` breaks mcp server until restart

Open Sillocan opened this issue 7 months ago • 3 comments

Describe the bug

When making a tools/call JSON-RPC request to the mcp server, passing the arguments parameter as a string instead of an object causes the server to stop responding to all HTTP requests. The server does not return an informative error, and requires a restart to recover.

This makes it possible for a single malformed client request to break the server for everyone.

See the MRE at https://github.com/Sillocan/fastmcp-mre-breaking-task-group-with-invalid-arguments/blob/main/python_sdk/simple_echo_mre.py

To Reproduce Steps to reproduce the behavior:

  1. Run the following command in your shell:
    uv run --with=git+https://github.com/Sillocan/fastmcp-mre-breaking-task-group-with-invalid-arguments#subdirectory=python_sdk mre
    
  2. Observe that a single malformed request (arguments is a string) returns 200, but further requests return 500 and the server must be restarted.
  3. See error output below.

Expected behavior

  • The server should return a clear error response (such as HTTP 400) when a request has an invalid format—specifically, when arguments is a string instead of a dict/object.
  • The server should remain operational and handle all further requests correctly, even after processing an invalid or malformed request.

Logs

$ uv run --with=git+https://github.com/Sillocan/fastmcp-mre-breaking-task-group-with-invalid-arguments#subdirectory=python_sdk mre
      Built fastmcp-mre-breaking-task-group-with-invalid-arguments @ git+https://github.com/Sillocan/fastmcp-mre-breaking-task-group-with-invalid-arguments@3eeb959518a5eaa685c7619ebc7b20f5330f6235#subdirectory=python_sdk
Installed 32 packages in 5ms
[05/27/25 12:40:16] DEBUG    Using selector: EpollSelector                                                                                                                                                              selector_events.py:64
INFO:     Started server process [1783696]
INFO:     Waiting for application startup.
                    INFO     StreamableHTTP session manager started                                                                                                                                            streamable_http_manager.py:109
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
[05/27/25 12:40:17] DEBUG    connect_tcp.started host='127.0.0.1' port=8000 local_address=None timeout=5.0 socket_options=None                                                                                                   _trace.py:87
                    DEBUG    connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x7fcbb8fe4ec0>                                                                                                   _trace.py:87
                    DEBUG    send_request_headers.started request=<Request [b'POST']>                                                                                                                                            _trace.py:87
                    DEBUG    send_request_headers.complete                                                                                                                                                                       _trace.py:87
                    DEBUG    send_request_body.started request=<Request [b'POST']>                                                                                                                                               _trace.py:87
                    DEBUG    send_request_body.complete                                                                                                                                                                          _trace.py:87
                    DEBUG    receive_response_headers.started request=<Request [b'POST']>                                                                                                                                        _trace.py:87
                    DEBUG    Stateless mode: Creating new transport for this request                                                                                                                           streamable_http_manager.py:159
INFO:     127.0.0.1:51112 - "POST /mcp/ HTTP/1.1" 200 OK
                    DEBUG    receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'date', b'Tue, 27 May 2025 19:40:16 GMT'), (b'server', b'uvicorn'), (b'cache-control', b'no-cache, no-transform'),      _trace.py:87
                             (b'connection', b'keep-alive'), (b'content-type', b'text/event-stream'), (b'x-accel-buffering', b'no'), (b'Transfer-Encoding', b'chunked')])                                                                    
                    INFO     HTTP Request: POST http://127.0.0.1:8000/mcp/ "HTTP/1.1 200 OK"                                                                                                                                  _client.py:1740
                    DEBUG    receive_response_body.started request=<Request [b'POST']>                                                                                                                                           _trace.py:87
                    DEBUG    Closing SSE writer                                                                                                                                                                        streamable_http.py:486
                    INFO     StreamableHTTP session manager shutting down                                                                                                                                      streamable_http_manager.py:113
                    DEBUG    Got event: http.disconnect. Stop streaming.                                                                                                                                                           sse.py:182
ERROR:      + Exception Group Traceback (most recent call last):
  |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/routing.py", line 692, in lifespan
  |     async with self.lifespan_context(app) as maybe_state:
  |                ~~~~~~~~~~~~~~~~~~~~~^^^^^
  |   File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/contextlib.py", line 235, in __aexit__
  |     await self.gen.athrow(value)
  |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py", line 106, in run
  |     async with anyio.create_task_group() as tg:
  |                ~~~~~~~~~~~~~~~~~~~~~~~^^
  |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
  |     raise BaseExceptionGroup(
  |         "unhandled errors in a TaskGroup", self._exceptions
  |     ) from None
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py", line 171, in run_stateless_server
    |     async with http_transport.connect() as streams:
    |                ~~~~~~~~~~~~~~~~~~~~~~^^
    |   File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/contextlib.py", line 235, in __aexit__
    |     await self.gen.athrow(value)
    |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http.py", line 843, in connect
    |     async with anyio.create_task_group() as tg:
    |                ~~~~~~~~~~~~~~~~~~~~~~~^^
    |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
    |     raise BaseExceptionGroup(
    |         "unhandled errors in a TaskGroup", self._exceptions
    |     ) from None
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Exception Group Traceback (most recent call last):
      |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http.py", line 916, in connect
      |     yield read_stream, write_stream
      |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py", line 174, in run_stateless_server
      |     await self.app.run(
      |     ...<4 lines>...
      |     )
      |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/lowlevel/server.py", line 495, in run
      |     async with AsyncExitStack() as stack:
      |                ~~~~~~~~~~~~~~^^
      |   File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/contextlib.py", line 768, in __aexit__
      |     raise exc
      |   File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/contextlib.py", line 751, in __aexit__
      |     cb_suppress = await cb(*exc_details)
      |                   ^^^^^^^^^^^^^^^^^^^^^^
      |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/shared/session.py", line 220, in __aexit__
      |     return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
      |     raise BaseExceptionGroup(
      |         "unhandled errors in a TaskGroup", self._exceptions
      |     ) from None
      | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
      +-+---------------- 1 ----------------
        | Traceback (most recent call last):
        |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/session.py", line 147, in _receive_loop
        |     await super()._receive_loop()
        |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/shared/session.py", line 354, in _receive_loop
        |     validated_request = self._receive_request_type.model_validate(
        |         message.message.root.model_dump(
        |             by_alias=True, mode="json", exclude_none=True
        |         )
        |     )
        |   File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/pydantic/main.py", line 705, in model_validate
        |     return cls.__pydantic_validator__.validate_python(
        |            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        |         obj, strict=strict, from_attributes=from_attributes, context=context, by_alias=by_alias, by_name=by_name
        |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        |     )
        |     ^
        | pydantic_core._pydantic_core.ValidationError: 23 validation errors for ClientRequest
        | PingRequest.method
        |   Input should be 'ping' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | InitializeRequest.method
        |   Input should be 'initialize' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | InitializeRequest.params.protocolVersion
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | InitializeRequest.params.capabilities
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | InitializeRequest.params.clientInfo
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | CompleteRequest.method
        |   Input should be 'completion/complete' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | CompleteRequest.params.ref
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | CompleteRequest.params.argument
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | SetLevelRequest.method
        |   Input should be 'logging/setLevel' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | SetLevelRequest.params.level
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | GetPromptRequest.method
        |   Input should be 'prompts/get' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | GetPromptRequest.params.arguments
        |   Input should be a valid dictionary [type=dict_type, input_value='{"text": "imnestedtext"}', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/dict_type
        | ListPromptsRequest.method
        |   Input should be 'prompts/list' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | ListResourcesRequest.method
        |   Input should be 'resources/list' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | ListResourceTemplatesRequest.method
        |   Input should be 'resources/templates/list' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | ReadResourceRequest.method
        |   Input should be 'resources/read' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | ReadResourceRequest.params.uri
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | SubscribeRequest.method
        |   Input should be 'resources/subscribe' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | SubscribeRequest.params.uri
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | UnsubscribeRequest.method
        |   Input should be 'resources/unsubscribe' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        | UnsubscribeRequest.params.uri
        |   Field required [type=missing, input_value={'name': 'echo', 'argumen...text": "imnestedtext"}'}, input_type=dict]
        |     For further information visit https://errors.pydantic.dev/2.11/v/missing
        | CallToolRequest.params.arguments
        |   Input should be a valid dictionary [type=dict_type, input_value='{"text": "imnestedtext"}', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/dict_type
        | ListToolsRequest.method
        |   Input should be 'tools/list' [type=literal_error, input_value='tools/call', input_type=str]
        |     For further information visit https://errors.pydantic.dev/2.11/v/literal_error
        +------------------------------------

                    DEBUG    receive_response_body.complete                                                                                                                                                                      _trace.py:87
                    DEBUG    response_closed.started                                                                                                                                                                             _trace.py:87
                    DEBUG    response_closed.complete                                                                                                                                                                            _trace.py:87
breaking_response.status_code=200 breaking_response.text=''
                    DEBUG    send_request_headers.started request=<Request [b'GET']>                                                                                                                                             _trace.py:87
                    DEBUG    send_request_headers.complete                                                                                                                                                                       _trace.py:87
                    DEBUG    send_request_body.started request=<Request [b'GET']>                                                                                                                                                _trace.py:87
                    DEBUG    send_request_body.complete                                                                                                                                                                          _trace.py:87
                    DEBUG    receive_response_headers.started request=<Request [b'GET']>                                                                                                                                         _trace.py:87
INFO:     127.0.0.1:51112 - "GET /mcp/ HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/uvicorn/protocols/http/h11_impl.py", line 403, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        self.scope, self.receive, self.send
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/applications.py", line 112, in __call__
    await self.middleware_stack(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/middleware/errors.py", line 187, in __call__
    raise exc
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/middleware/errors.py", line 165, in __call__
    await self.app(scope, receive, _send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/routing.py", line 714, in __call__
    await self.middleware_stack(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/routing.py", line 734, in app
    await route.handle(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/starlette/routing.py", line 460, in handle
    await self.app(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/fastmcp/server.py", line 786, in handle_streamable_http
    await self.session_manager.handle_request(scope, receive, send)
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py", line 137, in handle_request
    raise RuntimeError("Task group is not initialized. Make sure to use run().")
RuntimeError: Task group is not initialized. Make sure to use run().
                    DEBUG    receive_response_headers.complete return_value=(b'HTTP/1.1', 500, b'Internal Server Error', [(b'date', b'Tue, 27 May 2025 19:40:16 GMT'), (b'server', b'uvicorn'), (b'content-length', b'21'),      _trace.py:87
                             (b'content-type', b'text/plain; charset=utf-8')])                                                                                                                                                               
                    INFO     HTTP Request: GET http://127.0.0.1:8000/mcp/ "HTTP/1.1 500 Internal Server Error"                                                                                                                _client.py:1740
                    DEBUG    receive_response_body.started request=<Request [b'GET']>                                                                                                                                            _trace.py:87
                    DEBUG    receive_response_body.complete                                                                                                                                                                      _trace.py:87
                    DEBUG    response_closed.started                                                                                                                                                                             _trace.py:87
                    DEBUG    response_closed.complete                                                                                                                                                                            _trace.py:87
                    DEBUG    close.started                                                                                                                                                                                       _trace.py:87
                    DEBUG    close.complete                                                                                                                                                                                      _trace.py:87
get_response.status_code=500 get_response.text='Internal Server Error'
Traceback (most recent call last):
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/bin/mre", line 12, in <module>
    sys.exit(main())
             ~~~~^^
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/simple_echo_mre.py", line 69, in main
    asyncio.run(run_and_break_the_mcp())
    ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
  File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/asyncio/runners.py", line 195, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "${HOME}/.local/share/uv/python/cpython-3.13.2-linux-x86_64-gnu/lib/python3.13/asyncio/base_events.py", line 725, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/simple_echo_mre.py", line 62, in run_and_break_the_mcp
    await breakit()
  File "${HOME}/.cache/uv/archive-v0/WBNLPmHcXZu1RxZu0HKOl/lib/python3.13/site-packages/simple_echo_mre.py", line 54, in breakit
    assert get_response.status_code < 300, "MCP is fully broken"
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: MCP is fully broken

Desktop (please complete the following information):

  • OS: Linux
  • Browser [e.g. chrome, safari]
  • Version: 1.9.1

Additional context

Sillocan avatar May 27 '25 19:05 Sillocan

The source of this appears to be caused by a lack of an exeception handler when validating the _receive_request_type https://github.com/modelcontextprotocol/python-sdk/blob/532b1176f9a71f0a41508b977fd280074176096d/src/mcp/shared/session.py#L355

Edit: It appears there is a PR which should hopefully address this issue https://github.com/modelcontextprotocol/python-sdk/pull/822

Sillocan avatar May 27 '25 23:05 Sillocan

Yup, TY for the report, we are pushing in a fix!

johnw188 avatar May 28 '25 00:05 johnw188

Yup, TY for the report, we are pushing in a fix!

Just validated the MRE does not occur when using your branch. Thanks!

Sillocan avatar May 28 '25 00:05 Sillocan

With #822 merged in this is resolved

Sillocan avatar Jun 16 '25 01:06 Sillocan