Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|^.secrets.baseline$",
"lines": null
},
"generated_at": "2026-04-13T06:40:07Z",
"generated_at": "2026-04-13T09:59:07Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -9714,55 +9714,55 @@
"hashed_secret": "9fb7fe1217aed442b04c0f5e43b5d5a7d3287097",
"is_secret": false,
"is_verified": false,
"line_number": 2877,
"line_number": 2994,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0",
"is_secret": false,
"is_verified": false,
"line_number": 3505,
"line_number": 3622,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
"is_secret": false,
"is_verified": false,
"line_number": 6259,
"line_number": 6376,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
"is_secret": false,
"is_verified": false,
"line_number": 6751,
"line_number": 6868,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
"is_secret": false,
"is_verified": false,
"line_number": 8098,
"line_number": 8215,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
"is_secret": false,
"is_verified": false,
"line_number": 8240,
"line_number": 8357,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
"is_secret": false,
"is_verified": false,
"line_number": 8616,
"line_number": 8733,
"type": "Secret Keyword",
"verified_result": null
}
Expand Down
16 changes: 10 additions & 6 deletions mcpgateway/services/tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4232,10 +4232,10 @@ async def invoke_tool(
# Build the payload based on integration type
payload = arguments.copy()

# Handle URL path parameter substitution (using local variable)
# Handle URL path and query parameter substitution (using local variable)
final_url = tool_url
if "{" in tool_url and "}" in tool_url:
# Extract path parameters from URL template and arguments
# Extract ALL parameters (path and query) from URL template
url_params = re.findall(r"\{(\w+)\}", tool_url)
url_substitutions = {}

Expand All @@ -4246,7 +4246,7 @@ async def invoke_tool(
else:
raise ToolInvocationError(f"Required URL parameter '{param}' not found in arguments")

# --- Extract query params from URL ---
# --- Extract query params from URL (after substitution) ---
parsed = urlparse(final_url)
final_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"

Expand All @@ -4260,9 +4260,6 @@ async def invoke_tool(
for qk, qv in payload.items():
if isinstance(qv, (dict, list)):
raise ToolInvocationError(f"Tool '{name}': query_mapping produced non-scalar value for parameter '{qk}'")
else:
# No query mapping — merge URL query params into the payload (query params override on collision)
payload.update(query_params)

# Headers are mapped from the original arguments (not the path-param-reduced payload)
# to preserve all available data for header injection.
Expand All @@ -4280,8 +4277,15 @@ async def invoke_tool(
rest_start_time = time.time()
try:
if method == "GET":
# For GET: merge extracted URL query params into payload; everything sent as query string
if not tool_query_mapping:
payload.update(query_params)
response = await asyncio.wait_for(self._http_client.get(final_url, params=payload, headers=headers), timeout=effective_timeout)
else:
# For POST/PUT/PATCH/DELETE: merge query params into the JSON body
# (preserves backward compatibility with existing tool configurations)
if not tool_query_mapping:
payload.update(query_params)
response = await asyncio.wait_for(self._http_client.request(method, final_url, json=payload, headers=headers), timeout=effective_timeout)
except (asyncio.TimeoutError, httpx.TimeoutException):
rest_elapsed_ms = (time.time() - rest_start_time) * 1000
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/mcpgateway/services/test_tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,123 @@ async def test_invoke_tool_rest_parameter_substitution(self, tool_service, mock_
headers=mock_tool.headers,
)

@pytest.mark.asyncio
async def test_invoke_tool_rest_post_with_path_query_and_body_params(self, tool_service, mock_tool, mock_global_config_obj, test_db):
"""Test POST request with path parameters, query parameters (with templates), and body parameters.

This test demonstrates the complete parameter handling:
- Path parameters (e.g., {user_id}) are substituted into the URL path
- Query parameters can also use templates (e.g., ?api_key={api_key})
- Static query parameters (e.g., ?version=v2) are preserved as-is
- Query parameters are merged into the JSON body for POST requests
- Remaining payload goes to the JSON body alongside merged query params
"""
mock_tool.integration_type = "REST"
mock_tool.request_type = "POST"
mock_tool.jsonpath_filter = ""
mock_tool.auth_value = None
# URL with path parameters AND templated query parameters
mock_tool.url = "http://example.com/api/users/{user_id}/posts?api_key={api_key}&version=v2"

# Payload contains: path param (user_id), query param (api_key), and body params (title, content)
payload = {
"user_id": 456, # Will be substituted into URL path
"api_key": "secret123", # Template in query string portion of URL; substituted then extracted as query param
"title": "New Post", # Will go to JSON body
"content": "Hello World", # Will go to JSON body
}

setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj)

mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.status_code = 200
mock_response.json = Mock(return_value={"id": 789, "status": "created"})

tool_service._http_client.request = AsyncMock(return_value=mock_response)

await tool_service.invoke_tool(test_db, "test_tool", payload, request_headers=None)

# Verify parameter handling for POST:
# 1. Path parameter substituted: /users/456/posts
# 2. Query param template substituted: api_key=secret123
# 3. Static query param preserved: version=v2
# 4. Query params merged into JSON body (backward-compatible behavior for POST)
# 5. Body params: title and content (user_id and api_key removed after path/query substitution)
tool_service._http_client.request.assert_called_once_with(
"POST",
"http://example.com/api/users/456/posts", # Path param substituted, query string stripped
json={"title": "New Post", "content": "Hello World", "api_key": "secret123", "version": "v2"}, # Body + merged query params
headers=mock_tool.headers,
)

@pytest.mark.asyncio
async def test_invoke_tool_rest_get_with_static_query_params_no_mapping(self, tool_service, mock_tool, mock_global_config_obj, test_db):
"""Test GET request with static URL query params and no query_mapping.

Verifies that query params extracted from the URL are merged into the
payload and sent together via params= on the GET request.
"""
mock_tool.integration_type = "REST"
mock_tool.request_type = "GET"
mock_tool.jsonpath_filter = ""
mock_tool.auth_value = None
mock_tool.url = "http://example.com/api/search?version=v2&format=json"

payload = {"q": "hello"}

setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj)

mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.status_code = 200
mock_response.json = Mock(return_value={"results": []})

tool_service._http_client.get = AsyncMock(return_value=mock_response)

await tool_service.invoke_tool(test_db, "test_tool", payload, request_headers=None)

# URL query params (version, format) are merged into payload alongside the user-provided "q"
tool_service._http_client.get.assert_called_once_with(
"http://example.com/api/search",
params={"q": "hello", "version": "v2", "format": "json"},
headers=mock_tool.headers,
)

@pytest.mark.asyncio
async def test_invoke_tool_rest_put_with_query_params(self, tool_service, mock_tool, mock_global_config_obj, test_db):
"""Test PUT request with URL query params merges them into the JSON body.

Verifies that non-GET methods other than POST (e.g. PUT) also merge
URL query params into the JSON body for backward compatibility.
"""
mock_tool.integration_type = "REST"
mock_tool.request_type = "PUT"
mock_tool.jsonpath_filter = ""
mock_tool.auth_value = None
mock_tool.url = "http://example.com/api/items/1?version=v2"

payload = {"name": "updated"}

setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj)

mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.status_code = 200
mock_response.json = Mock(return_value={"status": "ok"})

tool_service._http_client.request = AsyncMock(return_value=mock_response)

await tool_service.invoke_tool(test_db, "test_tool", payload, request_headers=None)

# Query params merged into JSON body, same as POST
tool_service._http_client.request.assert_called_once_with(
"PUT",
"http://example.com/api/items/1",
json={"name": "updated", "version": "v2"},
headers=mock_tool.headers,
)

@pytest.mark.asyncio
async def test_invoke_tool_rest_jq_filter_error_returns_error(self, tool_service, mock_tool, mock_global_config_obj, test_db):
"""Test REST tool invocation marks result as error when jq filter returns TextContent error."""
Expand Down
Loading