diff --git a/.env.example b/.env.example index a213f767a50..b8d4407299c 100644 --- a/.env.example +++ b/.env.example @@ -2136,6 +2136,11 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # TOOL_CONCURRENT_LIMIT=10 # GATEWAY_TOOL_NAME_SEPARATOR=- +# Maximum length of response text returned for non-JSON REST API responses +# Longer responses are truncated to prevent exposing excessive sensitive data +# Default: 5000 characters, Range: 1000-100000 +# REST_RESPONSE_TEXT_MAX_LENGTH=5000 + # Prompt Configuration # PROMPT_CACHE_SIZE=100 # MAX_PROMPT_SIZE=102400 diff --git a/.secrets.baseline b/.secrets.baseline index 3569c2ec04f..77a86a53674 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|go.sum|mcpgateway/sri_hashes.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-04-14T13:09:46Z", + "generated_at": "2026-04-14T14:08:10Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -4830,7 +4830,7 @@ "hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff", "is_secret": false, "is_verified": false, - "line_number": 2228, + "line_number": 2236, "type": "Secret Keyword", "verified_result": null } @@ -8624,7 +8624,7 @@ "hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc", "is_secret": false, "is_verified": false, - "line_number": 6376, + "line_number": 6907, "type": "Secret Keyword", "verified_result": null }, @@ -8632,7 +8632,7 @@ "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", "is_secret": false, "is_verified": false, - "line_number": 6868, + "line_number": 7399, "type": "Secret Keyword", "verified_result": null }, @@ -8640,7 +8640,7 @@ "hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295", "is_secret": false, "is_verified": false, - "line_number": 8215, + "line_number": 8746, "type": "Secret Keyword", "verified_result": null }, @@ -8648,7 +8648,7 @@ "hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2", "is_secret": false, "is_verified": false, - "line_number": 8357, + "line_number": 8888, "type": "Secret Keyword", "verified_result": null }, @@ -8656,7 +8656,7 @@ "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", "is_secret": false, "is_verified": false, - "line_number": 8733, + "line_number": 9264, "type": "Secret Keyword", "verified_result": null } diff --git a/mcpgateway/admin_ui/admin.js b/mcpgateway/admin_ui/admin.js index a6b798c1c95..14bcc856f0a 100644 --- a/mcpgateway/admin_ui/admin.js +++ b/mcpgateway/admin_ui/admin.js @@ -485,6 +485,7 @@ Admin.showUsageStatsModal = showUsageStatsModal; import { editTool, initToolSelect, + invokeTool, testTool, enrichTool, generateToolTestCases, @@ -496,6 +497,7 @@ import { Admin.editTool = editTool; Admin.initToolSelect = initToolSelect; +Admin.invokeTool = invokeTool; Admin.testTool = testTool; Admin.enrichTool = enrichTool; Admin.generateToolTestCases = generateToolTestCases; diff --git a/mcpgateway/admin_ui/tools.js b/mcpgateway/admin_ui/tools.js index 376e2a1107b..7a5603bddff 100644 --- a/mcpgateway/admin_ui/tools.js +++ b/mcpgateway/admin_ui/tools.js @@ -2916,8 +2916,11 @@ export const runToolValidation = async function (testIndex) { const payload = { jsonrpc: "2.0", id: Date.now(), - method: AppState.currentTestTool.name, - params, + method: "tools/call", + params: { + name: AppState.currentTestTool.name, + arguments: params, + }, }; // Parse custom headers from the passthrough headers field @@ -3199,14 +3202,16 @@ export const runToolTest = async function () { const runButton = document.querySelector('button[onclick="runToolTest()"]'); if (!form || !AppState.currentTestTool) { - console.error("Tool test form or current tool not found"); + console.error("Tool test form or current tool not found", { + form: !!form, + currentTestTool: AppState.currentTestTool, + }); showErrorMessage("Tool test form not available"); return; } // Prevent multiple concurrent test runs if (runButton && runButton.disabled) { - console.log("Tool test already running"); return; } @@ -3329,8 +3334,11 @@ export const runToolTest = async function () { const payload = { jsonrpc: "2.0", id: Date.now(), - method: AppState.currentTestTool.name, - params, + method: "tools/call", + params: { + name: AppState.currentTestTool.name, + arguments: params, + }, }; // Parse custom headers from the passthrough headers field @@ -3502,3 +3510,182 @@ export const cleanupToolTestModal = function () { console.error("Error cleaning up tool test modal:", error); } }; + +// =================================================================== +// TOOL INVOCATION (opens test modal by tool name) +// =================================================================== + +/** + * Fetch tool details from the API by name. + * @param {string} toolName - The name of the tool to fetch + * @returns {Promise} The tool object + */ +export async function fetchToolDetails(toolName) { + const response = await fetchWithTimeout( + `${window.ROOT_PATH}/admin/tools/${encodeURIComponent(toolName)}`, + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch tool details (${response.status}): ${errorText}` + ); + } + + return await response.json(); +} + +/** + * Create a form input field based on schema. + * @param {string} key - The field name + * @param {Object} schema - The field schema + * @param {boolean} isRequired - Whether the field is required + * @returns {HTMLElement} The input element + */ +export function createFormInput(key, schema, isRequired) { + let input; + const baseInputClass = + "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:text-gray-200"; + + if (schema.enum) { + input = document.createElement("select"); + input.className = baseInputClass; + schema.enum.forEach((option) => { + const opt = document.createElement("option"); + opt.value = option; + opt.textContent = option; + if (option === schema.default) { + opt.selected = true; + } + input.appendChild(opt); + }); + } else if (schema.type === "boolean") { + input = document.createElement("input"); + input.type = "checkbox"; + input.className = + "h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded dark:bg-gray-700"; + if (schema.default === true) { + input.checked = true; + } + } else if (schema.type === "number" || schema.type === "integer") { + input = document.createElement("input"); + input.type = "number"; + input.className = baseInputClass; + if (schema.default !== undefined) { + input.value = schema.default; + } + } else { + input = document.createElement("input"); + input.type = "text"; + input.className = baseInputClass; + if (schema.default !== undefined) { + input.value = schema.default; + } + } + + input.name = key; + if (isRequired) { + input.required = true; + } + + return input; +} + +/** + * Generate form fields from tool input schema. + * @param {Object} tool - The tool object with input_schema + */ +export function generateToolFormFields(tool) { + const formFields = safeGetElement("tool-test-form-fields"); + if (!formFields) return; + + // Clear existing fields safely + while (formFields.firstChild) { + formFields.removeChild(formFields.firstChild); + } + + if (!tool.input_schema || !tool.input_schema.properties) { + const noParams = document.createElement("p"); + noParams.className = "text-sm text-gray-500 dark:text-gray-400"; + noParams.textContent = "This tool has no input parameters."; + formFields.appendChild(noParams); + return; + } + + const properties = tool.input_schema.properties; + const required = tool.input_schema.required || []; + + for (const [key, schema] of Object.entries(properties)) { + const isRequired = required.includes(key); + const fieldDiv = document.createElement("div"); + + const label = document.createElement("label"); + label.className = + "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; + label.textContent = `${key}${isRequired ? " *" : ""}`; + fieldDiv.appendChild(label); + + if (schema.description) { + const desc = document.createElement("p"); + desc.className = "text-xs text-gray-500 dark:text-gray-400 mb-1"; + desc.textContent = schema.description; + fieldDiv.appendChild(desc); + } + + const input = createFormInput(key, schema, isRequired); + fieldDiv.appendChild(input); + formFields.appendChild(fieldDiv); + } +} + +/** + * Open tool test modal and fetch tool details from API. + * Called by the "Invoke" button in the Tools table. + * @param {string} toolName - The name of the tool to test + */ +export const invokeTool = async function (toolName) { + try { + const tool = await fetchToolDetails(toolName); + + // Store tool details in AppState for runToolTest to access + AppState.currentTestTool = tool; + + // Populate modal title and description + const titleEl = safeGetElement("tool-test-modal-title"); + const descEl = safeGetElement("tool-test-modal-description"); + if (titleEl) { + titleEl.textContent = `Test Tool: ${tool.displayName || tool.name}`; + } + if (descEl) { + descEl.textContent = tool.description || ""; + } + + // Clear previous results + const resultContainer = safeGetElement("tool-test-result"); + if (resultContainer) { + resultContainer.textContent = ""; + } + + // Show the modal + const modal = safeGetElement("tool-test-modal"); + if (modal) { + modal.classList.remove("hidden"); + } + + // Generate form fields based on input schema + if (typeof window.renderToolTestForm === "function") { + window.renderToolTestForm(tool); + } else { + generateToolFormFields(tool); + } + } catch (error) { + console.error("Error invoking tool:", error); + showErrorMessage("Failed to open tool test modal: " + error.message); + } +}; diff --git a/mcpgateway/config.py b/mcpgateway/config.py index 6cba8d704c2..069d49716ed 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -1603,6 +1603,14 @@ def parse_issuers(cls, v: Any) -> list[str]: max_tool_retries: int = 3 tool_rate_limit: int = 100 # requests per minute tool_concurrent_limit: int = 10 + rest_response_text_max_length: int = Field( + default=5000, + ge=1000, + le=100000, + description="Maximum length of response text to return for non-JSON REST API responses. " + "Longer responses are truncated to prevent exposing excessive sensitive data. " + "Default: 5000 characters. Range: 1000-100000.", + ) # Content Security - Size Limits content_max_resource_size: int = Field(default=102400, ge=1024, le=10485760, description="Maximum size in bytes for resource content (default: 100KB)") # 100KB # Minimum 1KB # Maximum 10MB diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index acb54e4e1e3..abe212ec1d7 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -20,6 +20,7 @@ import binascii from datetime import datetime, timezone from functools import lru_cache +import json # NOTE: httpx uses stdlib json, not orjson, so response.json() raises json.JSONDecodeError import logging import os import re @@ -294,6 +295,33 @@ def _decrypt_tool_headers_for_runtime(headers: Optional[Dict[str, Any]]) -> Dict return {key: _decrypt_tool_header_value(value) for key, value in headers.items()} +def _handle_json_parse_error(response, error, is_error_response: bool = False) -> dict: + """Handle JSON parsing failures with graceful fallback to raw text. + + Args: + response: The HTTP response object with .text attribute + error: The exception that was raised during JSON parsing + is_error_response: If True, logs as "error response", else "response" + + Returns: + Dictionary with response_text key containing the raw response text + (truncated to REST_RESPONSE_TEXT_MAX_LENGTH if longer to avoid exposing sensitive data), + or error details if response body is empty/None + """ + msg = "error response" if is_error_response else "response" + if not response.text: + logger.warning(f"Failed to parse JSON {msg}: {error}. Response body was empty.") + return {"error": "Empty response body"} + + max_length = settings.rest_response_text_max_length + text = response.text[:max_length] if len(response.text) > max_length else response.text + if len(response.text) > max_length: + logger.warning(f"Failed to parse JSON {msg}: {error}. Response truncated from {len(response.text)} to {max_length} characters.") + else: + logger.warning(f"Failed to parse JSON {msg}: {error}") + return {"response_text": text} + + @lru_cache(maxsize=256) def _compile_jq_filter(jq_filter: str): """Cache compiled jq filter program. @@ -421,6 +449,18 @@ def extract_using_jq(data, jq_filter=""): if not jq_filter or jq_filter == "": return data + # Validate that jq_filter looks like a valid jq expression + jq_filter_str = str(jq_filter).strip() + if not jq_filter_str: + return data + + # Check if it looks like an email address (common mistake when jsonpath_filter + # field contains corrupted data). Intentionally simple regex to avoid false + # positives with valid jq expressions like .foo|.bar + if re.match(r"^[^.\[\]|]+@[^.\[\]|]+\.[^.\[\]|]+$", jq_filter_str): + logger.warning(f"Invalid jq filter (email address): {jq_filter_str}. Treating as empty filter.") + return data + # Track if input was originally a string (for error handling) was_string = isinstance(data, str) @@ -4246,30 +4286,37 @@ async def invoke_tool( else: raise ToolInvocationError(f"Required URL parameter '{param}' not found in arguments") - # --- Extract query params from URL (after substitution) --- - parsed = urlparse(final_url) - final_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" - - query_params = {k: v[0] for k, v in parse_qs(parsed.query).items()} - - if tool_query_mapping: - # Only mapped payload keys (renamed) are kept, merged on top of URL query params. - # Unmapped payload keys are intentionally dropped (mapping acts as an allowlist). - payload = apply_mapping_into_target(payload, tool_query_mapping, query_params) - # Reject non-scalar values that would be inappropriate as query parameters. - 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}'") - - # Headers are mapped from the original arguments (not the path-param-reduced payload) - # to preserve all available data for header injection. - if tool_header_mapping: - _validate_header_mapping_targets(tool_header_mapping, name) - headers = apply_mapping_into_target(arguments.copy(), tool_header_mapping, headers) - # Reject header values containing CRLF or null bytes to prevent header injection. - for hdr_name, hdr_val in headers.items(): - if isinstance(hdr_val, str) and _INVALID_HEADER_VALUE_CHARS.search(hdr_val): - raise ToolInvocationError(f"Tool '{name}': header_mapping produced value with illegal characters for header '{hdr_name}'") + # --- Extract query params from URL if query_mapping or header_mapping is used --- + # When mappings are present (not None/empty), we strip query params from URL and apply transformations. + # When mappings are absent (None/empty), preserve query params in URL for signed URLs. + query_params = {} + # Treat empty dict same as None (no mapping configured) + has_query_mapping = tool_query_mapping is not None and tool_query_mapping != {} + has_header_mapping = tool_header_mapping is not None and tool_header_mapping != {} + + if has_query_mapping or has_header_mapping: + parsed = urlparse(final_url) + final_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + query_params = {k: v[0] for k, v in parse_qs(parsed.query).items()} + + if tool_query_mapping: + # Only mapped payload keys (renamed) are kept, merged on top of URL query params. + # Unmapped payload keys are intentionally dropped (mapping acts as an allowlist). + payload = apply_mapping_into_target(payload, tool_query_mapping, query_params) + # Reject non-scalar values that would be inappropriate as query parameters. + 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}'") + + # Headers are mapped from the original arguments (not the path-param-reduced payload) + # to preserve all available data for header injection. + if tool_header_mapping: + _validate_header_mapping_targets(tool_header_mapping, name) + headers = apply_mapping_into_target(arguments.copy(), tool_header_mapping, headers) + # Reject header values containing CRLF or null bytes to prevent header injection. + for hdr_name, hdr_val in headers.items(): + if isinstance(hdr_val, str) and _INVALID_HEADER_VALUE_CHARS.search(hdr_val): + raise ToolInvocationError(f"Tool '{name}': header_mapping produced value with illegal characters for header '{hdr_name}'") # Use the tool's request_type rather than defaulting to POST (using local variable) method = tool_request_type.upper() if tool_request_type else "POST" @@ -4277,15 +4324,31 @@ 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) + # For GET: Extract and merge URL query params with input arguments + if not has_query_mapping and not has_header_mapping: + # When no mappings (None or empty), extract query params from URL + parsed = urlparse(final_url) + final_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + query_params = {k: v[0] for k, v in parse_qs(parsed.query).items()} + + conflicts = set(payload.keys()) & set(query_params.keys()) + if conflicts: + logger.warning( + f"REST tool GET request has conflicting parameters between URL and input arguments. " + f"URL query params will take precedence for: {', '.join(sorted(conflicts))}. " + f"Tool: {name}" + ) + + 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: + # For POST/PUT/PATCH/DELETE: Different behavior based on mapping presence + if has_query_mapping or has_header_mapping: + # When mappings are used (not None/empty), query params were already extracted and mapped + # Merge them into the JSON body for backward compatibility with mapped tools payload.update(query_params) + # else: No mappings (None or empty) - preserve query params in URL for signed URL support + # (Azure SAS, AWS presigned URLs, webhook signatures, etc.) 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 @@ -4315,17 +4378,39 @@ async def invoke_tool( await self._run_timeout_post_invoke(name, effective_timeout, global_context, context_table, plugin_manager) raise ToolTimeoutError(f"Tool invocation timed out after {effective_timeout}s") - response.raise_for_status() + try: + response.raise_for_status() + except httpx.HTTPStatusError: + # Non-2xx response — parse body (may be HTML, plain text, XML, etc.) + try: + result = response.json() + except (json.JSONDecodeError, orjson.JSONDecodeError, UnicodeDecodeError, AttributeError) as e: + result = _handle_json_parse_error(response, e, is_error_response=True) + if "error" in result: + error_val = result["error"] + elif "response_text" in result: + error_val = f"HTTP {response.status_code}: {result['response_text']}" + else: + error_val = f"HTTP {response.status_code}" + tool_result = ToolResult( + content=[TextContent(type="text", text=error_val if isinstance(error_val, str) else orjson.dumps(error_val).decode())], + is_error=True, + structured_content={"status_code": response.status_code}, + ) + # Don't mark as successful — success remains False # Handle 204 No Content responses that have no body - if response.status_code == 204: + if tool_result is not None and tool_result.is_error: + pass # Already handled by HTTPStatusError above + elif response.status_code == 204: tool_result = ToolResult(content=[TextContent(type="text", text="Request completed successfully (No Content)")]) success = True elif response.status_code not in [200, 201, 202, 206]: + # Non-standard 2xx codes (203, 205, 207, etc.) treated as errors try: result = response.json() - except orjson.JSONDecodeError: - result = {"response_text": response.text} if response.text else {} + except (json.JSONDecodeError, orjson.JSONDecodeError, UnicodeDecodeError, AttributeError) as e: + result = _handle_json_parse_error(response, e, is_error_response=True) error_val = result["error"] if "error" in result else "Tool error encountered" tool_result = ToolResult( content=[TextContent(type="text", text=error_val if isinstance(error_val, str) else orjson.dumps(error_val).decode())], @@ -4335,8 +4420,8 @@ async def invoke_tool( else: try: result = response.json() - except orjson.JSONDecodeError: - result = {"response_text": response.text} if response.text else {} + except (json.JSONDecodeError, orjson.JSONDecodeError, UnicodeDecodeError, AttributeError) as e: + result = _handle_json_parse_error(response, e, is_error_response=False) logger.debug(f"REST API tool response: {result}") filtered_response = extract_using_jq(result, tool_jsonpath_filter) # Check if extract_using_jq returned an error (list of TextContent objects) diff --git a/tests/unit/js/tools.test.js b/tests/unit/js/tools.test.js index 27cfd6fab8b..f4c2b971ebe 100644 --- a/tests/unit/js/tools.test.js +++ b/tests/unit/js/tools.test.js @@ -18,6 +18,10 @@ import { validateTool, cleanupToolTestState, cleanupToolTestModal, + fetchToolDetails, + createFormInput, + generateToolFormFields, + invokeTool, } from "../../../mcpgateway/admin_ui/tools.js"; import { fetchWithTimeout } from "../../../mcpgateway/admin_ui/utils"; import { openModal, closeModal } from "../../../mcpgateway/admin_ui/modals"; @@ -1768,7 +1772,8 @@ describe("runToolTest - additional", () => { ); await runToolTest(); - expect(consoleSpy).toHaveBeenCalledWith("Tool test already running"); + // When button is disabled, runToolTest returns silently without making a request + expect(fetchWithTimeout).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); @@ -2189,7 +2194,7 @@ describe("runToolTest - scalar parameter types", () => { const { runToolTest } = await import("../../../mcpgateway/admin_ui/tools.js"); await runToolTest(); const body = JSON.parse(fetchWithTimeout.mock.calls.at(-1)[1].body); - expect(body.params[paramKey]).toEqual(expected); + expect(body.params.arguments[paramKey]).toEqual(expected); consoleSpy.mockRestore(); }); @@ -2244,7 +2249,7 @@ describe("runToolTest - array parameter types", () => { const { runToolTest } = await import("../../../mcpgateway/admin_ui/tools.js"); await runToolTest(); const body = JSON.parse(fetchWithTimeout.mock.calls.at(-1)[1].body); - expect(body.params[paramKey]).toEqual(expected); + expect(body.params.arguments[paramKey]).toEqual(expected); consoleSpy.mockRestore(); }); @@ -3113,3 +3118,445 @@ describe("initToolSelect - Select All respects search filter", () => { vi.unstubAllGlobals(); }); }); + +// --------------------------------------------------------------------------- +// fetchToolDetails +// --------------------------------------------------------------------------- +describe("fetchToolDetails", () => { + test("successfully fetches tool details", async () => { + window.ROOT_PATH = ""; + + const mockTool = { + name: "test-tool", + displayName: "Test Tool", + description: "A test tool", + input_schema: { + properties: { + param1: { type: "string" }, + }, + }, + }; + + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTool), + }); + + const result = await fetchToolDetails("test-tool"); + + expect(fetchWithTimeout).toHaveBeenCalledWith( + "/admin/tools/test-tool", + expect.objectContaining({ + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + ); + expect(result).toEqual(mockTool); + }); + + test("handles error when fetch fails", async () => { + window.ROOT_PATH = ""; + + fetchWithTimeout.mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve("Not found"), + }); + + await expect(fetchToolDetails("nonexistent-tool")).rejects.toThrow( + "Failed to fetch tool details (404): Not found" + ); + }); + + test("encodes tool name in URL", async () => { + window.ROOT_PATH = ""; + + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + await fetchToolDetails("tool/with/slashes"); + + expect(fetchWithTimeout).toHaveBeenCalledWith( + "/admin/tools/tool%2Fwith%2Fslashes", + expect.any(Object) + ); + }); +}); + +// --------------------------------------------------------------------------- +// createFormInput +// --------------------------------------------------------------------------- +describe("createFormInput", () => { + test("creates select input for enum schema", () => { + const schema = { + type: "string", + enum: ["option1", "option2", "option3"], + default: "option2", + }; + + const input = createFormInput("testField", schema, false); + + expect(input.tagName).toBe("SELECT"); + expect(input.name).toBe("testField"); + expect(input.required).toBe(false); + expect(input.options.length).toBe(3); + expect(input.options[0].value).toBe("option1"); + expect(input.options[1].selected).toBe(true); // default + }); + + test("creates checkbox for boolean schema", () => { + const schema = { + type: "boolean", + default: true, + }; + + const input = createFormInput("boolField", schema, false); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("checkbox"); + expect(input.name).toBe("boolField"); + expect(input.checked).toBe(true); + }); + + test("creates number input for number schema", () => { + const schema = { + type: "number", + default: 42, + }; + + const input = createFormInput("numField", schema, true); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("number"); + expect(input.name).toBe("numField"); + expect(input.value).toBe("42"); + expect(input.required).toBe(true); + }); + + test("creates number input for integer schema", () => { + const schema = { + type: "integer", + default: 10, + }; + + const input = createFormInput("intField", schema, false); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("number"); + expect(input.name).toBe("intField"); + expect(input.value).toBe("10"); + }); + + test("creates text input for string schema", () => { + const schema = { + type: "string", + default: "default value", + }; + + const input = createFormInput("strField", schema, true); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("text"); + expect(input.name).toBe("strField"); + expect(input.value).toBe("default value"); + expect(input.required).toBe(true); + }); + + test("creates text input for unspecified type", () => { + const schema = {}; + + const input = createFormInput("unknownField", schema, false); + + expect(input.tagName).toBe("INPUT"); + expect(input.type).toBe("text"); + expect(input.name).toBe("unknownField"); + }); +}); + +// --------------------------------------------------------------------------- +// generateToolFormFields +// --------------------------------------------------------------------------- +describe("generateToolFormFields", () => { + test("generates form fields from tool schema", () => { + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + document.body.appendChild(formFields); + + const tool = { + name: "test-tool", + input_schema: { + properties: { + param1: { + type: "string", + description: "First parameter", + }, + param2: { + type: "number", + description: "Second parameter", + }, + }, + required: ["param1"], + }, + }; + + generateToolFormFields(tool); + + const fields = formFields.children; + expect(fields.length).toBe(2); + + // Check first field (required) + const firstField = fields[0]; + const firstLabel = firstField.querySelector("label"); + expect(firstLabel.textContent).toBe("param1 *"); + const firstDesc = firstField.querySelector("p"); + expect(firstDesc.textContent).toBe("First parameter"); + const firstInput = firstField.querySelector('input[name="param1"]'); + expect(firstInput).toBeTruthy(); + expect(firstInput.required).toBe(true); + + // Check second field (not required) + const secondField = fields[1]; + const secondLabel = secondField.querySelector("label"); + expect(secondLabel.textContent).toBe("param2"); + const secondInput = secondField.querySelector('input[name="param2"]'); + expect(secondInput).toBeTruthy(); + expect(secondInput.required).toBe(false); + }); + + test("displays message when tool has no parameters", () => { + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + document.body.appendChild(formFields); + + const tool = { + name: "test-tool", + input_schema: {}, + }; + + generateToolFormFields(tool); + + expect(formFields.children.length).toBe(1); + expect(formFields.children[0].textContent).toBe( + "This tool has no input parameters." + ); + }); + + test("clears existing fields before generating new ones", () => { + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + const existingChild = document.createElement("div"); + existingChild.textContent = "Old content"; + formFields.appendChild(existingChild); + document.body.appendChild(formFields); + + const tool = { + name: "test-tool", + input_schema: { + properties: { + newParam: { type: "string" }, + }, + }, + }; + + generateToolFormFields(tool); + + expect(formFields.children.length).toBe(1); + expect(formFields.textContent).not.toContain("Old content"); + }); + + test("returns early if form fields element not found", () => { + const tool = { + name: "test-tool", + input_schema: { + properties: { + param1: { type: "string" }, + }, + }, + }; + + // Should not throw error + expect(() => generateToolFormFields(tool)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// invokeTool +// --------------------------------------------------------------------------- +describe("invokeTool", () => { + test("successfully opens tool test modal", async () => { + window.ROOT_PATH = ""; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockTool = { + name: "test-tool", + displayName: "Test Tool", + description: "A test tool", + input_schema: { + properties: { + param1: { type: "string" }, + }, + }, + }; + + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTool), + }); + + const titleEl = document.createElement("div"); + titleEl.id = "tool-test-modal-title"; + document.body.appendChild(titleEl); + + const descEl = document.createElement("div"); + descEl.id = "tool-test-modal-description"; + document.body.appendChild(descEl); + + const resultContainer = document.createElement("div"); + resultContainer.id = "tool-test-result"; + document.body.appendChild(resultContainer); + + const modal = document.createElement("div"); + modal.id = "tool-test-modal"; + modal.classList.add("hidden"); + document.body.appendChild(modal); + + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + document.body.appendChild(formFields); + + await invokeTool("test-tool"); + + expect(fetchWithTimeout).toHaveBeenCalled(); + expect(titleEl.textContent).toBe("Test Tool: Test Tool"); + expect(descEl.textContent).toBe("A test tool"); + expect(modal.classList.contains("hidden")).toBe(false); + expect(formFields.children.length).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + }); + + test("uses tool name when displayName is not available", async () => { + window.ROOT_PATH = ""; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockTool = { + name: "test-tool", + description: "A test tool", + input_schema: {}, + }; + + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTool), + }); + + const titleEl = document.createElement("div"); + titleEl.id = "tool-test-modal-title"; + document.body.appendChild(titleEl); + + const descEl = document.createElement("div"); + descEl.id = "tool-test-modal-description"; + document.body.appendChild(descEl); + + const resultContainer = document.createElement("div"); + resultContainer.id = "tool-test-result"; + document.body.appendChild(resultContainer); + + const modal = document.createElement("div"); + modal.id = "tool-test-modal"; + modal.classList.add("hidden"); + document.body.appendChild(modal); + + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + document.body.appendChild(formFields); + + await invokeTool("test-tool"); + + expect(titleEl.textContent).toBe("Test Tool: test-tool"); + + consoleSpy.mockRestore(); + }); + + test("handles fetch error gracefully", async () => { + window.ROOT_PATH = ""; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { showErrorMessage } = await import( + "../../../mcpgateway/admin_ui/utils" + ); + + fetchWithTimeout.mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve("Not found"), + }); + + await invokeTool("nonexistent-tool"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error invoking tool:", + expect.any(Error) + ); + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("Failed to open tool test modal") + ); + + consoleSpy.mockRestore(); + }); + + test("calls window.renderToolTestForm if available", async () => { + window.ROOT_PATH = ""; + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockTool = { + name: "test-tool", + displayName: "Test Tool", + description: "A test tool", + input_schema: { + properties: { + param1: { type: "string" }, + }, + }, + }; + + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTool), + }); + + const titleEl = document.createElement("div"); + titleEl.id = "tool-test-modal-title"; + document.body.appendChild(titleEl); + + const descEl = document.createElement("div"); + descEl.id = "tool-test-modal-description"; + document.body.appendChild(descEl); + + const resultContainer = document.createElement("div"); + resultContainer.id = "tool-test-result"; + document.body.appendChild(resultContainer); + + const modal = document.createElement("div"); + modal.id = "tool-test-modal"; + modal.classList.add("hidden"); + document.body.appendChild(modal); + + const formFields = document.createElement("div"); + formFields.id = "tool-test-form-fields"; + document.body.appendChild(formFields); + + const mockRenderToolTestForm = vi.fn(); + window.renderToolTestForm = mockRenderToolTestForm; + + await invokeTool("test-tool"); + + expect(mockRenderToolTestForm).toHaveBeenCalledWith(mockTool); + + delete window.renderToolTestForm; + consoleSpy.mockRestore(); + }); +}); diff --git a/tests/unit/mcpgateway/services/test_tool_service.py b/tests/unit/mcpgateway/services/test_tool_service.py index 1da14e8c48f..3b6a94c8e3a 100644 --- a/tests/unit/mcpgateway/services/test_tool_service.py +++ b/tests/unit/mcpgateway/services/test_tool_service.py @@ -2219,12 +2219,12 @@ async def test_invoke_tool_rest_parameter_substitution(self, tool_service, mock_ 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: + This test demonstrates the complete parameter handling (no mappings = signed URL mode): - 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 + - Query parameters are preserved in URL for POST (signed URL support) + - Remaining payload goes to the JSON body (without query params) """ mock_tool.integration_type = "REST" mock_tool.request_type = "POST" @@ -2252,16 +2252,16 @@ async def test_invoke_tool_rest_post_with_path_query_and_body_params(self, tool_ await tool_service.invoke_tool(test_db, "test_tool", payload, request_headers=None) - # Verify parameter handling for POST: + # Verify parameter handling for POST (no mappings = signed URL support): # 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) + # 4. Query params STAY in URL (not merged into body) for signed URL support # 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 + "http://example.com/api/users/456/posts?api_key=secret123&version=v2", # Path param substituted, query params preserved + json={"title": "New Post", "content": "Hello World"}, # Only body params headers=mock_tool.headers, ) @@ -2324,11 +2324,11 @@ async def test_invoke_tool_rest_put_with_query_params(self, tool_service, mock_t await tool_service.invoke_tool(test_db, "test_tool", payload, request_headers=None) - # Query params merged into JSON body, same as POST + # Query params preserved in URL for signed URL support (no mappings) tool_service._http_client.request.assert_called_once_with( "PUT", - "http://example.com/api/items/1", - json={"name": "updated", "version": "v2"}, + "http://example.com/api/items/1?version=v2", + json={"name": "updated"}, headers=mock_tool.headers, ) @@ -4335,10 +4335,12 @@ async def test_run_timeout_post_invoke_noop_without_plugin_manager(self, tool_se {"existing": "1", "mapped_query": "test"}, {"Content-Type": "application/json", "X-Mapped-Query": "test"}, ), + # When both mappings are None or empty, query params are preserved in URL (signed URL support) + # Only input args go in the body. ( None, None, - {"query": "test", "existing": "1"}, + {"query": "test"}, # Only input args, URL query params stay in URL {"Content-Type": "application/json"}, ), ( @@ -4353,10 +4355,11 @@ async def test_run_timeout_post_invoke_noop_without_plugin_manager(self, tool_se {"query": "test", "existing": "1"}, {"Content-Type": "application/json", "X-Query": "test"}, ), + # Empty dict mappings also preserve query params in URL (same as None) ( {}, {}, - {"query": "test", "existing": "1"}, + {"query": "test"}, # Only input args, URL query params stay in URL {"Content-Type": "application/json"}, ), ], @@ -4396,12 +4399,27 @@ async def test_invoke_tool_rest_headers_and_query_maps_applied( ): await tool_service.invoke_tool(test_db, "test_tool", {"query": "test"}, request_headers=None) - tool_service._http_client.request.assert_called_once_with( - "POST", - "http://example.com/tools/test", - json=expected_json, - headers=expected_headers, - ) + # When both mappings are None or empty dict, query params stay in URL (signed URL support) + # When mappings have actual values, query params are extracted and merged into body + has_query_mapping = tool_query_mapping is not None and tool_query_mapping != {} + has_header_mapping = tool_header_mapping is not None and tool_header_mapping != {} + + if not has_query_mapping and not has_header_mapping: + # No mappings (None or empty) - query params preserved in URL + tool_service._http_client.request.assert_called_once_with( + "POST", + "http://example.com/tools/test?existing=1", # Query params preserved in URL + json=expected_json, + headers=expected_headers, + ) + else: + # Mappings present - query params extracted and merged into body + tool_service._http_client.request.assert_called_once_with( + "POST", + "http://example.com/tools/test", + json=expected_json, + headers=expected_headers, + ) @pytest.mark.asyncio async def test_invoke_tool_rest_get_with_query_mapping(self, tool_service, mock_tool, mock_global_config_obj, test_db): @@ -4851,6 +4869,519 @@ def test_empty_filter_bypasses_cache(self): assert result is data +# --------------------------------------------------------------------------- # +# Tests for REST Tool Improvements (non-JSON, query params) # +# --------------------------------------------------------------------------- # + + +class TestJqFilterEmailValidation: + """Tests for JQ filter email address validation (#3855).""" + + def test_extract_using_jq_rejects_email_addresses(self): + """Simple email addresses are detected and ignored as jq filters.""" + data = {"key": "value", "user": "test"} + + result = extract_using_jq(data, "user@example.com") + assert result == data, "Simple email addresses should be ignored as jq filters" + + result = extract_using_jq(data, "admin@test.org") + assert result == data + + result = extract_using_jq(data, "testuser@domain.net") + assert result == data + + def test_extract_using_jq_accepts_valid_filters(self): + """Valid jq filters still work after email validation.""" + data = {"key": "value", "nested": {"field": 123}} + + result = extract_using_jq(data, ".key") + assert result == ["value"] + + result = extract_using_jq(data, ".nested.field") + assert result == [123] + + def test_extract_using_jq_empty_whitespace_filters(self): + """Empty/whitespace filters are handled.""" + data = {"key": "value"} + + result = extract_using_jq(data, "") + assert result == data + + result = extract_using_jq(data, " ") + assert result == data + + result = extract_using_jq(data, "\t\n") + assert result == data + + +class TestRestToolQueryParamHandling: + """Tests for query parameter handling in REST tools (#3857).""" + + @pytest.mark.asyncio + async def test_rest_tool_get_merges_query_params(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """GET requests merge URL query params with input arguments.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.url = "https://api.example.com/search?api_key=secret123" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + 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) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"q": "test query", "limit": 10}, request_headers=None) + + call_args = tool_service._http_client.get.call_args + assert call_args[0][0] == "https://api.example.com/search" + params = call_args[1]["params"] + assert params["api_key"] == "secret123" + assert params["q"] == "test query" + assert params["limit"] == 10 + + @pytest.mark.asyncio + async def test_rest_tool_post_preserves_query_params_in_url(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """POST requests preserve query params in URL (signed URL support).""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "POST" + mock_tool.url = "https://storage.example.com/upload?signature=xyz&expires=123" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.json = Mock(return_value={"success": True}) + + tool_service._http_client.request = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"filename": "test.txt", "content": "data"}, request_headers=None) + + call_args = tool_service._http_client.request.call_args + url = call_args[0][1] + assert "signature=xyz" in url + assert "expires=123" in url + + body = call_args[1]["json"] + assert body == {"filename": "test.txt", "content": "data"} + + @pytest.mark.asyncio + async def test_rest_tool_put_preserves_query_params(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """PUT requests preserve query params in URL.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "PUT" + mock_tool.url = "https://api.example.com/resource?token=abc123" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.json = Mock(return_value={"updated": True}) + + tool_service._http_client.request = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"data": "updated"}, request_headers=None) + + call_args = tool_service._http_client.request.call_args + url = call_args[0][1] + assert "token=abc123" in url + + @pytest.mark.asyncio + async def test_rest_tool_patch_preserves_query_params(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """PATCH requests preserve query params in URL.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "PATCH" + mock_tool.url = "https://api.example.com/resource?version=v2" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.json = Mock(return_value={"patched": True}) + + tool_service._http_client.request = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"field": "value"}, request_headers=None) + + call_args = tool_service._http_client.request.call_args + url = call_args[0][1] + assert "version=v2" in url + body = call_args[1]["json"] + assert body == {"field": "value"} + + @pytest.mark.asyncio + async def test_rest_tool_delete_preserves_query_params(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """DELETE requests preserve query params in URL.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "DELETE" + mock_tool.url = "https://api.example.com/resource?cascade=true" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 204 + mock_response.text = "" + mock_response.json = Mock(return_value={}) + + tool_service._http_client.request = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"confirm": "yes"}, request_headers=None) + + call_args = tool_service._http_client.request.call_args + url = call_args[0][1] + assert "cascade=true" in url + + @pytest.mark.asyncio + async def test_rest_tool_get_param_conflict_logs_warning(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """GET request logs warning when input args conflict with URL query params.""" + import logging + + caplog.set_level(logging.WARNING) + + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.url = "https://api.example.com/search?api_key=url_value&safe=true" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + 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) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool( + test_db, + "test_tool", + { + "api_key": "input_value", # pragma: allowlist secret + "q": "search" + }, + request_headers=None) + + assert "conflicting parameters" in caplog.text.lower() + assert "api_key" in caplog.text + + call_args = tool_service._http_client.get.call_args + params = call_args[1]["params"] + assert params["api_key"] == "url_value" # URL value wins + assert params["safe"] == "true" + assert params["q"] == "search" + + @pytest.mark.asyncio + async def test_rest_tool_get_empty_url_params(self, tool_service, mock_tool, mock_global_config_obj, test_db): + """GET request with no URL query params works correctly.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.url = "https://api.example.com/search" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + 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) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + await tool_service.invoke_tool(test_db, "test_tool", {"q": "test"}, request_headers=None) + + call_args = tool_service._http_client.get.call_args + params = call_args[1]["params"] + assert params == {"q": "test"} + + +class TestRestToolNonJsonResponses: + """Tests for handling non-JSON responses from REST tools (#3855).""" + + @pytest.mark.asyncio + async def test_rest_tool_handles_html_error_response(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool handles HTML error pages gracefully without crashing.""" + import httpx + + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + # raise_for_status must actually raise for 500 to exercise the real code path + mock_request = Mock(spec=httpx.Request) + mock_request.url = "https://api.example.com/test" + mock_response.raise_for_status = Mock(side_effect=httpx.HTTPStatusError("Server Error", request=mock_request, response=mock_response)) + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.is_error is True + # Status code must be preserved in structured_content for retry plugin + assert result.structured_content == {"status_code": 500} + # Error message must include the HTTP status code + assert "500" in result.content[0].text + assert "Failed to parse JSON error response" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_handles_plain_text_response(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool handles plain text responses without crashing.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = "Plain text response" + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.content[0].text is not None + assert "Failed to parse JSON response" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_handles_xml_response(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool handles XML responses without crashing.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = 'value' + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.content[0].text is not None + assert "Failed to parse JSON response" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_handles_unicode_decode_error(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool handles invalid UTF-8 encoding without crashing.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = "Invalid encoding content" + mock_response.json = Mock(side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid")) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.content[0].text is not None + assert "Failed to parse JSON response" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_handles_empty_response_body(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool handles empty response body with JSON parse error.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = "" + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.content[0].text is not None + result_text = result.content[0].text + assert "Empty response body" in result_text + assert "Response body was empty" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_truncates_large_response_text(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool truncates response text exceeding REST_RESPONSE_TEXT_MAX_LENGTH.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + large_text = "X" * 10000 + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = large_text + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + assert result.content[0].text is not None + result_data = orjson.loads(result.content[0].text) + assert "response_text" in result_data + assert len(result_data["response_text"]) == settings.rest_response_text_max_length + assert f"Response truncated from {len(large_text)} to {settings.rest_response_text_max_length} characters" in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_does_not_truncate_small_response(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool does not truncate response text below the limit.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + small_text = "Small response text" + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = small_text + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + with patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + result_data = orjson.loads(result.content[0].text) + assert result_data["response_text"] == small_text + assert "Response truncated" not in caplog.text + + @pytest.mark.asyncio + async def test_rest_tool_truncation_respects_config_value(self, tool_service, mock_tool, mock_global_config_obj, test_db, caplog): + """REST tool truncation uses the configured REST_RESPONSE_TEXT_MAX_LENGTH value.""" + mock_tool.integration_type = "REST" + mock_tool.request_type = "GET" + mock_tool.jsonpath_filter = "" + mock_tool.auth_value = None + + setup_db_execute_mock(test_db, mock_tool, mock_global_config_obj) + + large_text = "Y" * 6000 + mock_response = AsyncMock() + mock_response.raise_for_status = Mock() + mock_response.status_code = 200 + mock_response.text = large_text + import json + + mock_response.json = Mock(side_effect=json.JSONDecodeError("Expecting value", "", 0)) + + tool_service._http_client.get = AsyncMock(return_value=mock_response) + + mock_metrics_buffer = Mock() + mock_metrics_buffer.record_tool_metric = Mock() + + with ( + patch("mcpgateway.services.tool_service.metrics_buffer", mock_metrics_buffer), + patch.object(settings, "rest_response_text_max_length", 2000), + ): + result = await tool_service.invoke_tool(test_db, "test_tool", {}, request_headers=None) + + result_data = orjson.loads(result.content[0].text) + assert len(result_data["response_text"]) == 2000 + assert "Response truncated from 6000 to 2000 characters" in caplog.text + + class TestSchemaValidatorCaching: """Tests for JSON Schema validator caching (#1809)."""