LitServe icon indicating copy to clipboard operation
LitServe copied to clipboard

LitServe MCP Bug: tools/call Always Fails

Open ductai199x opened this issue 8 months ago • 4 comments

🐛 Bug

MCP tools/call requests consistently fail with endpoint_handler() missing 1 required positional argument: 'request' while tools/list works perfectly. This makes MCP integration completely unusable.

Environment

  • LitServe Version: 0.2.12
  • MCP Version: 1.9.4
  • Python Version: 3.12.3
  • Operating System: Linux 5.15.167.4-microsoft-standard-WSL2 (WSL2)
  • Platform: x86_64

Bug Details

What Works ✅

  • tools/list requests work perfectly
  • Direct REST API calls to /predict work fine
  • Tool discovery and schema generation work correctly

What's Broken ❌

  • All tools/call requests fail with identical error
  • Affects any LitAPI with MCP enabled
  • 100% failure rate across all tested configurations

Error Message

LitServer._register_api_endpoints.<locals>.endpoint_handler() missing 1 required positional argument: 'request'

Root Cause Analysis

The bug is in /litserve/mcp.py at line 461:

# This line fails:
return await _call_handler(handler, **arguments)

The issue:

  1. handler is the FastAPI endpoint_handler function which expects a request parameter
  2. _call_handler tries to bind MCP arguments as kwargs to this function
  3. FastAPI endpoint handlers require a positional request object, not kwargs
  4. The binding fails because no request argument is provided

The FastAPI endpoint signature is:

async def endpoint_handler(request: request_type) -> response_type:
    return await handler.handle_request(request, request_type)

Minimal Reproduction Case

1. Create Simple LitAPI with MCP

# minimal_mcp_repro.py
import litserve as ls
from litserve.mcp import MCP
from pydantic import BaseModel

class SimpleMCPAPI(ls.LitAPI):
    def setup(self, device):
        pass
    
    def decode_request(self, request):
        if isinstance(request, dict):
            return request
        return request.dict() if hasattr(request, 'dict') else {"message": str(request)}
    
    def predict(self, inputs):
        return {"response": f"Received: {inputs.get('message', 'unknown')}"}
    
    def encode_response(self, output):
        return output

if __name__ == "__main__":
    api = SimpleMCPAPI(
        mcp=MCP(
            name="simple_test",
            description="Simple test API",
            input_schema={
                "type": "object",
                "properties": {
                    "message": {"type": "string"}
                },
                "required": ["message"]
            }
        )
    )
    
    server = ls.LitServer(api)
    server.run(port=8000)

2. Test MCP Endpoints

# test_mcp_bug.py
import requests
import json

def test_mcp_bug():
    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream"
    }
    
    # This works ✅
    tools_list = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/list",
        "params": {}
    }
    
    # This fails ❌  
    tools_call = {
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
            "name": "simple_test",
            "arguments": {"message": "test"}
        }
    }
    
    print("Testing tools/list...")
    response = requests.post("http://localhost:8000/mcp/", json=tools_list, headers=headers)
    print(f"Status: {response.status_code}")
    if response.status_code == 200:
        print("✅ tools/list works")
    else:
        print(f"❌ tools/list failed: {response.text}")
    
    print("\nTesting tools/call...")
    response = requests.post("http://localhost:8000/mcp/", json=tools_call, headers=headers)
    print(f"Status: {response.status_code}")
    if response.status_code == 200:
        result = response.json()
        if result.get('result', {}).get('isError'):
            print(f"❌ tools/call failed: {result['result']['content'][0]['text']}")
        else:
            print("✅ tools/call works")
    else:
        print(f"❌ tools/call HTTP error: {response.text}")

if __name__ == "__main__":
    test_mcp_bug()

3. Run Reproduction

# Terminal 1: Start server
python minimal_mcp_repro.py

# Terminal 2: Test bug
python test_mcp_bug.py

Expected Output:

Testing tools/list...
Status: 200
✅ tools/list works

Testing tools/call...
Status: 200
❌ tools/call failed: LitServer._register_api_endpoints.<locals>.endpoint_handler() missing 1 required positional argument: 'request'

Comprehensive Testing Evidence

We tested 11 different configurations:

  • 4 different header combinations (standard, event-stream only, with session, explicit streaming)
  • 3 different payload complexities
  • 5 different endpoint paths
  • All streaming formats and HTTP configurations

Additional Notes

  • The bug appears to be in the design of how MCP integration calls FastAPI endpoints
  • tools/list works because it doesn't go through the same code path
  • Direct REST API calls work fine, proving the LitAPI implementation is correct
  • The error is consistent across all Python versions, operating systems, and configurations tested

ductai199x avatar Jun 29 '25 00:06 ductai199x

I was able to modify the mcp.py file and it's working in my tests. I also added schema validation and error handling for invalid input parameters. Let me know if you'd like me to create a PR.

ductai199x avatar Jun 29 '25 01:06 ductai199x

Hey @ductai199x, thanks for the comprehensive issue! Feel free to ahead to add the PR for the fix 🙌

aniketmaurya avatar Jun 29 '25 07:06 aniketmaurya

Also, btw since in LitServe the decode_request argument is bound to be called request - the MCP properties must be request.

mcp=MCP(
            name="simple_test",
            description="Simple test API",
            input_schema={
                "type": "object",
                "properties": {
-                    "message": {"type": "string"}
+                    "request": {"type": "string"}
                },
                "required": ["message"]
            }
        )

aniketmaurya avatar Jun 29 '25 09:06 aniketmaurya

Also, btw since in LitServe the decode_request argument is bound to be called request - the MCP properties must be request.

I think you are a bit confused here because def decode_request(self, request) doesn't mean the MCP schema should have a "request" field. The request parameter is just the method signature, not a schema field.

Basically, my understanding is (and forgive me if I am wrong):

  1. MCP schema = What the client sends ({"message": "test"})
  2. decode_request parameter = What LitServe passes to the method (should be the full request object)
  3. The bug = LitServe's MCP handler doesn't pass the request parameter to decode_request

In mcp.py:

        async def _call_tool(name: str, arguments: dict):
            print(f"_call_tool called, name: {name}, arguments: {arguments}") # debug print here
            try:
                endpoint_path = self.tool_endpoint_connections[name]
                logger.debug(f"call tool called, endpoint: {endpoint_path}, arguments: {arguments}")
                if endpoint_path is None:
                    raise ValueError(f"Tool {name} not found")

                logger.debug(f"call tool called, endpoint: {endpoint_path}, arguments: {arguments}")
                for route in app.routes:
                    if route.path == endpoint_path:
                        handler = route.endpoint
                        break
                else:
                    raise ValueError(f"Endpoint {endpoint_path} not found")
                logger.debug(f"call tool called, returning: {handler}")
                print(f"Calling handler: {handler} with arguments: {arguments}") # debug print here
                return await _call_handler(handler, **arguments)
            except Exception as e:
                logger.error(f"Error calling tool {name}: {e}")
                raise e

and

            async def _call_handler(handler, **kwargs):
                sig = inspect.signature(handler)
                bound = sig.bind_partial(**{
                    k: (v if not issubclass(p.annotation, BaseModel) else p.annotation(**v))
                    for k, v in kwargs.items()
                    for name, p in sig.parameters.items()
                    if k == name
                })
                print(f"Calling handler: {handler} with arguments: {bound.args}, {bound.kwargs}") # debug print here
                return _convert_to_content(await handler(*bound.args, **bound.kwargs))

The log will say:

_call_tool called, name: simple_test, arguments: {'message": "test'}
Calling handler: <function LitServer._register_api_endpoints.<locals>.endpoint_handler at 0x7f42cd728680> with arguments: {'message': 'test'}
Calling handler: <function LitServer._register_api_endpoints.<locals>.endpoint_handler at 0x7f42cd728680> with arguments: (), {}

What happens:

  1. handler is the LitAPI's endpoint_handler method
  2. endpoint_handler has signature: endpoint_handler(request)
  3. kwargs contains {'message': 'test'}
  4. The comprehension looks for parameters named 'message' in endpoint_handler
  5. But endpoint_handler only has parameter 'request', not 'message'
  6. Result: All arguments get filtered out, bound.args and bound.kwargs become empty

Also, even if we modify this to just say:

async def _call_handler(handler, **kwargs):
    return _convert_to_content(await handler(kwargs))

it wouldn't still work! because the endpoint handler requires an actual http request object, not a dictionary.

The fix is probably non-trivial here. My suggestion is to store the LitAPI reference method pointer directly as a class member, then call LitAPI methods directly, bypassing FastAPI entirely. But I imagine that there are a couple of options:

  1. Option 1: Fix the Handler Signature Matching + Create a Mock Request Object
  2. Option 2: Bypass FastAPI Layer (my approach)

I went with option 2 because:

  1. No need to convert between MCP dicts and FastAPI Request objects
  2. Calls LitAPI methods as intended (decode → predict → encode)
  3. Avoids creating fake Request objects with potential edge cases
  4. MCP tools should work with the data, not HTTP mechanics

ductai199x avatar Jun 29 '25 16:06 ductai199x