Repository: IBM/mcp-context-forge

Issue:
[BUG] Tool invocation returns 'Invalid method' error after PR #746
## Problem
After PR #746, tool invocations through the RPC endpoint fail with an 'Invalid method' error when using the traditional format where the tool name is used directly as the method.

### Error Response
```json
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32000,
    "message": "Invalid method",
    "data": {
      "query": "test",
      "limit": 5
    }
  },
  "id": 1755258291346
}
```

## Root Cause
PR #746 introduced a breaking change in how tools are invoked via the RPC endpoint:

### Before PR #746
Tools could be invoked directly using the tool name as the method:
```json
{
  "jsonrpc": "2.0",
  "method": "my_tool_name",
  "params": {
    "arg1": "value1"
  }
}
```

### After PR #746
Tools must now be invoked using the `tools/call` method with the tool name in parameters:
```json
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "my_tool_name",
    "arguments": {
      "arg1": "value1"
    }
  }
}
```

## Impact
This breaking change affects all existing clients that invoke tools using the old format, causing them to receive 'Invalid method' errors.

## Solution Implemented
Added backward compatibility support in `mcpgateway/main.py` (lines 2320-2336) that:
1. Attempts to invoke unknown methods as tools directly (supporting old format)
2. Falls back to gateway forwarding if not a tool
3. Only returns 'Invalid method' error if both attempts fail

This allows both formats to work:
- **Old format**: `method: "tool_name"` with params as arguments
- **New format**: `method: "tools/call"` with tool name and arguments in params

## Test Coverage
Created comprehensive test suites:
- `tests/unit/mcpgateway/test_rpc_tool_invocation.py` - Tests for new format and RPC methods
- `tests/unit/mcpgateway/test_rpc_backward_compatibility.py` - Tests for backward compatibility

All tests pass, confirming both formats work correctly.

## Recommendation
Consider documenting this change in the migration guide or changelog to help users transition from the old to the new format while maintaining backward compatibility.


Discussion:

Patch:
diff --git a/mcpgateway/main.py b/mcpgateway/main.py
index 26e30292..1b2c59b3 100644
--- a/mcpgateway/main.py
+++ b/mcpgateway/main.py
@@ -2318,7 +2318,22 @@ async def handle_rpc(request: Request, db: Session = Depends(get_db), user: str
         elif method.startswith("logging/"):
             result = {}
         else:
-            raise JSONRPCError(-32000, "Invalid method", params)
+            # Backward compatibility: Try to invoke as a tool directly
+            # This allows both old format (method=tool_name) and new format (method=tools/call)
+            headers = {k.lower(): v for k, v in request.headers.items()}
+            try:
+                result = await tool_service.invoke_tool(db=db, name=method, arguments=params, request_headers=headers)
+                if hasattr(result, "model_dump"):
+                    result = result.model_dump(by_alias=True, exclude_none=True)
+            except (ValueError, Exception):
+                # If not a tool, try forwarding to gateway
+                try:
+                    result = await gateway_service.forward_request(db, method, params)
+                    if hasattr(result, "model_dump"):
+                        result = result.model_dump(by_alias=True, exclude_none=True)
+                except Exception:
+                    # If all else fails, return invalid method error
+                    raise JSONRPCError(-32000, "Invalid method", params)
 
         return {"jsonrpc": "2.0", "result": result, "id": req_id}
 


Test patch:
diff --git a/tests/unit/mcpgateway/cache/test_session_registry.py b/tests/unit/mcpgateway/cache/test_session_registry.py
index 0740f022..ce3b94f6 100644
--- a/tests/unit/mcpgateway/cache/test_session_registry.py
+++ b/tests/unit/mcpgateway/cache/test_session_registry.py
@@ -478,7 +478,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -523,7 +523,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -562,7 +562,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -604,7 +604,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -645,7 +645,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -725,7 +725,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -766,7 +766,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
@@ -807,7 +807,7 @@ async def __aenter__(self):
 
         async def __aexit__(self, exc_type, exc_val, exc_tb):
             return None
-    
+
     with patch(
         "mcpgateway.cache.session_registry.ResilientHttpClient",
         MockAsyncClient
diff --git a/tests/unit/mcpgateway/test_rpc_backward_compatibility.py b/tests/unit/mcpgateway/test_rpc_backward_compatibility.py
new file mode 100644
index 00000000..23ffd719
--- /dev/null
+++ b/tests/unit/mcpgateway/test_rpc_backward_compatibility.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+"""Test backward compatibility for tool invocation after PR #746."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy.orm import Session
+
+from mcpgateway.main import app
+
+
+@pytest.fixture
+def client():
+    """Create a test client."""
+    return TestClient(app)
+
+
+@pytest.fixture
+def mock_db():
+    """Create a mock database session."""
+    return MagicMock(spec=Session)
+
+
+class TestRPCBackwardCompatibility:
+    """Test backward compatibility for RPC tool invocation."""
+
+    def test_old_format_tool_invocation_with_backward_compatibility(self, client, mock_db):
+        """Test that old format (direct tool name as method) still works with backward compatibility."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.invoke_tool", new_callable=AsyncMock) as mock_invoke:
+                    mock_invoke.return_value = {"result": "success", "data": "test data from old format"}
+
+                    # Old format: tool name directly as method
+                    request_body = {"jsonrpc": "2.0", "method": "my_custom_tool", "params": {"query": "test query", "limit": 10}, "id": 123}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert result["result"]["result"] == "success"
+                    assert result["result"]["data"] == "test data from old format"
+                    assert result["id"] == 123
+
+                    # Verify the tool was invoked with correct parameters
+                    mock_invoke.assert_called_once()
+                    call_args = mock_invoke.call_args
+                    assert call_args.kwargs["name"] == "my_custom_tool"
+                    assert call_args.kwargs["arguments"] == {"query": "test query", "limit": 10}
+
+    def test_new_format_tool_invocation_still_works(self, client, mock_db):
+        """Test that new format (tools/call method) continues to work."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.invoke_tool", new_callable=AsyncMock) as mock_invoke:
+                    mock_invoke.return_value = {"result": "success", "data": "test data from new format"}
+
+                    # New format: tools/call method
+                    request_body = {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "my_custom_tool", "arguments": {"query": "test query", "limit": 10}}, "id": 456}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert result["result"]["result"] == "success"
+                    assert result["result"]["data"] == "test data from new format"
+                    assert result["id"] == 456
+
+                    # Verify the tool was invoked with correct parameters
+                    mock_invoke.assert_called_once()
+                    call_args = mock_invoke.call_args
+                    assert call_args.kwargs["name"] == "my_custom_tool"
+                    assert call_args.kwargs["arguments"] == {"query": "test query", "limit": 10}
+
+    def test_both_formats_invoke_same_tool(self, client, mock_db):
+        """Test that both old and new formats can invoke the same tool successfully."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.invoke_tool", new_callable=AsyncMock) as mock_invoke:
+                    mock_invoke.return_value = {"result": "success"}
+
+                    # Test old format
+                    old_format_request = {"jsonrpc": "2.0", "method": "search_tool", "params": {"query": "old format"}, "id": 1}
+
+                    response_old = client.post("/rpc", json=old_format_request)
+                    assert response_old.status_code == 200
+
+                    # Reset mock
+                    mock_invoke.reset_mock()
+
+                    # Test new format
+                    new_format_request = {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "search_tool", "arguments": {"query": "new format"}}, "id": 2}
+
+                    response_new = client.post("/rpc", json=new_format_request)
+                    assert response_new.status_code == 200
+
+                    # Both should have invoked the tool
+                    assert mock_invoke.call_count == 1
+                    call_args = mock_invoke.call_args
+                    assert call_args.kwargs["name"] == "search_tool"
+                    assert call_args.kwargs["arguments"]["query"] == "new format"
+
+    def test_invalid_method_still_returns_error(self, client, mock_db):
+        """Test that truly invalid methods still return an error."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.invoke_tool", new_callable=AsyncMock) as mock_invoke:
+                    # Simulate tool not found
+                    mock_invoke.side_effect = ValueError("Tool not found")
+
+                    with patch("mcpgateway.main.gateway_service.forward_request", new_callable=AsyncMock) as mock_forward:
+                        # Simulate gateway forward also failing
+                        mock_forward.side_effect = ValueError("Not a gateway method")
+
+                        request_body = {"jsonrpc": "2.0", "method": "completely_invalid_method", "params": {}, "id": 999}
+
+                        response = client.post("/rpc", json=request_body)
+
+                        assert response.status_code == 200
+                        result = response.json()
+                        assert result["jsonrpc"] == "2.0"
+                        assert "error" in result
+                        assert result["error"]["code"] == -32000
+                        assert result["error"]["message"] == "Invalid method"
+                        assert result["id"] == 999
diff --git a/tests/unit/mcpgateway/test_rpc_tool_invocation.py b/tests/unit/mcpgateway/test_rpc_tool_invocation.py
new file mode 100644
index 00000000..47bcd7fb
--- /dev/null
+++ b/tests/unit/mcpgateway/test_rpc_tool_invocation.py
@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""Test RPC tool invocation after PR #746 changes."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy.orm import Session
+
+from mcpgateway.main import app
+from mcpgateway.models import Tool
+from mcpgateway.services.tool_service import ToolService
+from mcpgateway.config import settings
+
+
+@pytest.fixture
+def client():
+    """Create a test client."""
+    return TestClient(app)
+
+
+@pytest.fixture
+def mock_db():
+    """Create a mock database session."""
+    return MagicMock(spec=Session)
+
+
+@pytest.fixture
+def mock_tool_service():
+    """Create a mock tool service."""
+    service = AsyncMock(spec=ToolService)
+    return service
+
+
+@pytest.fixture
+def sample_tool():
+    """Create a sample tool for testing."""
+    return Tool(
+        name="test_tool",
+        url="http://localhost:8000/test",
+        description="A test tool",
+        input_schema={"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "number", "default": 5}}, "required": ["query"]},
+    )
+
+
+class TestRPCToolInvocation:
+    """Test class for RPC tool invocation."""
+
+    def test_tools_call_method_new_format(self, client, mock_db):
+        """Test tool invocation using the new tools/call method format."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.invoke_tool", new_callable=AsyncMock) as mock_invoke:
+                    mock_invoke.return_value = {"result": "success", "data": "test data"}
+
+                    request_body = {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "test_tool", "arguments": {"query": "test", "limit": 5}}, "id": 1}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert result["id"] == 1
+
+                    mock_invoke.assert_called_once()
+                    call_args = mock_invoke.call_args
+                    assert call_args.kwargs["name"] == "test_tool"
+                    assert call_args.kwargs["arguments"] == {"query": "test", "limit": 5}
+
+    def test_direct_tool_invocation_fails(self, client, mock_db):
+        """Test that direct tool invocation (old format) now fails with 'Invalid method'."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                request_body = {"jsonrpc": "2.0", "method": "test_tool", "params": {"query": "test", "limit": 5}, "id": 1}  # Direct tool name as method (old format)
+
+                response = client.post("/rpc", json=request_body)
+
+                assert response.status_code == 200
+                result = response.json()
+                assert result["jsonrpc"] == "2.0"
+                assert "error" in result
+                assert result["error"]["code"] == -32000
+                assert result["error"]["message"] == "Invalid method"
+                assert result["error"]["data"] == {"query": "test", "limit": 5}
+                assert result["id"] == 1
+
+    def test_tools_list_method(self, client, mock_db):
+        """Test the tools/list method."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.tool_service.list_tools", new_callable=AsyncMock) as mock_list:
+                    sample_tool = MagicMock()
+                    sample_tool.model_dump.return_value = {"name": "test_tool", "description": "A test tool"}
+                    mock_list.return_value = [sample_tool]
+
+                    request_body = {"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 2}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert "tools" in result["result"]
+                    assert len(result["result"]["tools"]) == 1
+                    assert result["result"]["tools"][0]["name"] == "test_tool"
+
+    def test_resources_read_method(self, client, mock_db):
+        """Test the resources/read method."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.resource_service.read_resource", new_callable=AsyncMock) as mock_read:
+                    mock_read.return_value = {"uri": "test://resource", "content": "test content"}
+
+                    request_body = {"jsonrpc": "2.0", "method": "resources/read", "params": {"uri": "test://resource"}, "id": 3}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert "contents" in result["result"]
+
+    def test_prompts_get_method(self, client, mock_db):
+        """Test the prompts/get method."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.prompt_service.get_prompt", new_callable=AsyncMock) as mock_get:
+                    mock_prompt = MagicMock()
+                    mock_prompt.model_dump.return_value = {"name": "test_prompt", "description": "A test prompt", "messages": []}
+                    mock_get.return_value = mock_prompt
+
+                    request_body = {"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test_prompt", "arguments": {}}, "id": 4}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+
+    def test_initialize_method(self, client, mock_db):
+        """Test the initialize method."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                with patch("mcpgateway.main.session_registry.handle_initialize_logic", new_callable=AsyncMock) as mock_init:
+                    mock_init.return_value = MagicMock(model_dump=MagicMock(return_value={"protocolVersion": "1.0", "capabilities": {}, "serverInfo": {"name": "test-server"}}))
+
+                    request_body = {"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "1.0", "capabilities": {}, "clientInfo": {"name": "test-client"}}, "id": 5}
+
+                    response = client.post("/rpc", json=request_body)
+
+                    assert response.status_code == 200
+                    result = response.json()
+                    assert result["jsonrpc"] == "2.0"
+                    assert "result" in result
+                    assert result["result"]["protocolVersion"] == "1.0"
+
+    @pytest.mark.parametrize(
+        "method,expected_result_key",
+        [
+            ("tools/list", "tools"),
+            ("resources/list", "resources"),
+            ("prompts/list", "prompts"),
+            ("list_gateways", "gateways"),
+            ("list_roots", "roots"),
+        ],
+    )
+    def test_list_methods_return_proper_structure(self, client, mock_db, method, expected_result_key):
+        """Test that all list methods return results in the proper structure."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                # Mock all possible service methods
+                with patch("mcpgateway.main.tool_service.list_tools", new_callable=AsyncMock, return_value=[]):
+                    with patch("mcpgateway.main.resource_service.list_resources", new_callable=AsyncMock, return_value=[]):
+                        with patch("mcpgateway.main.prompt_service.list_prompts", new_callable=AsyncMock, return_value=[]):
+                            with patch("mcpgateway.main.gateway_service.list_gateways", new_callable=AsyncMock, return_value=[]):
+                                with patch("mcpgateway.main.root_service.list_roots", new_callable=AsyncMock, return_value=[]):
+                                    request_body = {"jsonrpc": "2.0", "method": method, "params": {}, "id": 100}
+
+                                    response = client.post("/rpc", json=request_body)
+
+                                    assert response.status_code == 200
+                                    result = response.json()
+                                    assert result["jsonrpc"] == "2.0"
+                                    assert "result" in result
+                                    assert expected_result_key in result["result"]
+                                    assert isinstance(result["result"][expected_result_key], list)
+
+    def test_unknown_method_returns_error(self, client, mock_db):
+        """Test that unknown methods return an appropriate error."""
+        with patch("mcpgateway.config.settings.auth_required", False):
+            with patch("mcpgateway.main.get_db", return_value=mock_db):
+                request_body = {"jsonrpc": "2.0", "method": "unknown/method", "params": {}, "id": 999}
+
+                response = client.post("/rpc", json=request_body)
+
+                assert response.status_code == 200
+                result = response.json()
+                assert result["jsonrpc"] == "2.0"
+                assert "error" in result
+                assert result["error"]["code"] == -32000
+                assert result["error"]["message"] == "Invalid method"
+                assert result["id"] == 999
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])