LitServe MCP Bug: tools/call Always Fails
🐛 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/listrequests work perfectly - Direct REST API calls to
/predictwork fine - Tool discovery and schema generation work correctly
What's Broken ❌
-
All
tools/callrequests 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:
-
handleris the FastAPIendpoint_handlerfunction which expects arequestparameter -
_call_handlertries to bind MCP arguments as kwargs to this function - FastAPI endpoint handlers require a positional
requestobject, not kwargs - The binding fails because no
requestargument 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/listworks 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
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.
Hey @ductai199x, thanks for the comprehensive issue! Feel free to ahead to add the PR for the fix 🙌
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"]
}
)
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):
- MCP schema = What the client sends (
{"message": "test"}) -
decode_requestparameter = What LitServe passes to the method (should be the fullrequestobject) - The bug = LitServe's MCP handler doesn't pass the
requestparameter todecode_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:
- handler is the LitAPI's endpoint_handler method
- endpoint_handler has signature: endpoint_handler(request)
- kwargs contains {'message': 'test'}
- The comprehension looks for parameters named 'message' in endpoint_handler
- But endpoint_handler only has parameter 'request', not 'message'
- 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:
- Option 1: Fix the Handler Signature Matching + Create a Mock Request Object
- Option 2: Bypass FastAPI Layer (my approach)
I went with option 2 because:
- No need to convert between MCP dicts and FastAPI Request objects
- Calls LitAPI methods as intended (decode → predict → encode)
- Avoids creating fake Request objects with potential edge cases
- MCP tools should work with the data, not HTTP mechanics