From 5920933090c32e84d69536964e50d741e2abfaf8 Mon Sep 17 00:00:00 2001 From: Olivier Gintrand Date: Tue, 14 Apr 2026 17:33:53 +0200 Subject: [PATCH] feat(observability): auto-instrument httpx for outbound trace propagation Signed-off-by: Olivier Gintrand --- .env.example | 5 - gunicorn.config.py | 17 + mcpgateway/admin_ui/admin.js | 2 - mcpgateway/admin_ui/tools.js | 199 +----- mcpgateway/config.py | 8 - mcpgateway/observability.py | 19 +- pyproject.toml | 41 +- tests/unit/js/tools.test.js | 453 +------------- .../mcpgateway/services/test_tool_service.py | 567 +----------------- 9 files changed, 81 insertions(+), 1230 deletions(-) diff --git a/.env.example b/.env.example index b8d4407299..a213f767a5 100644 --- a/.env.example +++ b/.env.example @@ -2136,11 +2136,6 @@ 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/gunicorn.config.py b/gunicorn.config.py index 7de75ededa..e1612f10dd 100644 --- a/gunicorn.config.py +++ b/gunicorn.config.py @@ -127,6 +127,23 @@ def post_fork(server, worker): except ImportError: pass + # Re-apply httpx instrumentation in each worker process. + # With preload_app=True, the master process sets _is_instrumented_by_opentelemetry=True + # and applies wrap_function_wrapper patches. After fork(), workers inherit the flag + # but NOT the monkey-patches. We must uninstrument (reset the flag) then re-instrument. + try: + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + instrumentor = HTTPXClientInstrumentor() + if instrumentor.is_instrumented_by_opentelemetry: + instrumentor.uninstrument() + instrumentor.instrument() + server.log.info("Worker %s: httpx instrumentation applied", worker.pid) + except ImportError: + pass + except Exception as e: + server.log.warning("Worker %s: failed to instrument httpx: %s", worker.pid, e) + def post_worker_init(worker): worker.log.info("worker initialization completed") diff --git a/mcpgateway/admin_ui/admin.js b/mcpgateway/admin_ui/admin.js index 14bcc856f0..a6b798c1c9 100644 --- a/mcpgateway/admin_ui/admin.js +++ b/mcpgateway/admin_ui/admin.js @@ -485,7 +485,6 @@ Admin.showUsageStatsModal = showUsageStatsModal; import { editTool, initToolSelect, - invokeTool, testTool, enrichTool, generateToolTestCases, @@ -497,7 +496,6 @@ 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 7a5603bddf..376e2a1107 100644 --- a/mcpgateway/admin_ui/tools.js +++ b/mcpgateway/admin_ui/tools.js @@ -2916,11 +2916,8 @@ export const runToolValidation = async function (testIndex) { const payload = { jsonrpc: "2.0", id: Date.now(), - method: "tools/call", - params: { - name: AppState.currentTestTool.name, - arguments: params, - }, + method: AppState.currentTestTool.name, + params, }; // Parse custom headers from the passthrough headers field @@ -3202,16 +3199,14 @@ 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", { - form: !!form, - currentTestTool: AppState.currentTestTool, - }); + console.error("Tool test form or current tool not found"); showErrorMessage("Tool test form not available"); return; } // Prevent multiple concurrent test runs if (runButton && runButton.disabled) { + console.log("Tool test already running"); return; } @@ -3334,11 +3329,8 @@ export const runToolTest = async function () { const payload = { jsonrpc: "2.0", id: Date.now(), - method: "tools/call", - params: { - name: AppState.currentTestTool.name, - arguments: params, - }, + method: AppState.currentTestTool.name, + params, }; // Parse custom headers from the passthrough headers field @@ -3510,182 +3502,3 @@ 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 069d49716e..6cba8d704c 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -1603,14 +1603,6 @@ 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/observability.py b/mcpgateway/observability.py index cd7f3f7d38..b7c819fb2f 100644 --- a/mcpgateway/observability.py +++ b/mcpgateway/observability.py @@ -113,7 +113,6 @@ class _ConsoleSpanExporterStub: # pragma: no cover - test patch replaces this logging.getLogger(__name__).debug("Skipping OpenTelemetry shim setup: %s", exc) # First-Party -from mcpgateway import __version__ # noqa: E402 # pylint: disable=wrong-import-position from mcpgateway.config import get_settings # noqa: E402 # pylint: disable=wrong-import-position from mcpgateway.utils.correlation_id import get_correlation_id # noqa: E402 # pylint: disable=wrong-import-position from mcpgateway.utils.log_sanitizer import sanitize_for_log # noqa: E402 # pylint: disable=wrong-import-position @@ -844,7 +843,7 @@ def init_telemetry() -> Optional[Any]: # Create resource attributes resource_attributes: Dict[str, Any] = { "service.name": cfg.otel_service_name, - "service.version": __version__, + "service.version": "1.0.0-RC-2", "deployment.environment": _get_deployment_environment(), } @@ -989,7 +988,7 @@ def on_end(self, span): # Get tracer # Obtain a tracer if trace API available; otherwise create a no-op tracer if trace is not None and hasattr(trace, "get_tracer"): - _TRACER = cast(Any, trace).get_tracer("mcp-gateway", __version__, schema_url="https://opentelemetry.io/schemas/1.11.0") + _TRACER = cast(Any, trace).get_tracer("mcp-gateway", "1.0.0-RC-2", schema_url="https://opentelemetry.io/schemas/1.11.0") else: class _NoopTracer: @@ -1008,6 +1007,20 @@ def start_as_current_span(self, _name: str): # type: ignore[override] _TRACER = _NoopTracer() + # Auto-instrument httpx for outbound trace context propagation + # This ensures traceparent headers are injected into all HTTP requests + # made to backend MCP servers, enabling distributed tracing across the gateway. + try: + # Third-Party + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # type: ignore[import-untyped] + + HTTPXClientInstrumentor().instrument() + logger.info(" httpx instrumentation enabled (outbound trace propagation)") + except ImportError: + logger.debug("opentelemetry-instrumentation-httpx not installed — outbound propagation disabled") + except Exception as inst_err: + logger.warning("Failed to instrument httpx: %s", inst_err) + logger.info(f"✅ OpenTelemetry initialized with {exporter_type} exporter") if exporter_type == "otlp": logger.info(f" Endpoint: {_resolve_otlp_endpoint()}") diff --git a/pyproject.toml b/pyproject.toml index 77992e0aac..5d4a7627e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ exclude-newer-package = { "cpex-rate-limiter" = "2026-04-09T23:59:59Z", "cpex-en # ---------------------------------------------------------------- [project] name = "mcp-contextforge-gateway" -version = "1.0.0-RC-3" +version = "1.0.0-rc3" description = "ContextForge AI Gateway — an AI gateway, registry, and proxy for MCP, A2A, and REST/gRPC APIs. Exposes a unified control plane with centralized governance, discovery, and observability. Optimizes agent and tool calling, and supports plugins." keywords = ["MCP","API","gateway","proxy","tools", "agents","agentic ai","model context protocol","multi-agent","fastapi", @@ -53,34 +53,34 @@ maintainers = [ dependencies = [ "alembic>=1.18.4", "argon2-cffi>=25.1.0", - "cryptography>=46.0.7", - "fastapi>=0.135.3", + "cryptography>=46.0.6", + "fastapi>=0.135.1", "filelock>=3.25.2", - "gunicorn>=25.3.0", + "gunicorn>=25.1.0", "httpx>=0.28.1", "httpx[http2]>=0.28.1", "jinja2>=3.1.6", "jq>=1.11.0", "jsonpath-ng>=1.8.0", "jsonschema>=4.26.0", - "mcp>=1.27.0", - "orjson>=3.11.8", + "mcp>=1.26.0", + "orjson>=3.11.7", "parse>=1.21.1", "prometheus-fastapi-instrumentator>=7.1.0", - "prometheus_client>=0.24.1", # 0.25.0 exceeds 10-day min-release-age policy + "prometheus_client>=0.24.1", "psutil>=7.2.2", "pydantic>=2.12.5", "pydantic[email]>=2.12.5", "pydantic-settings>=2.13.1", "pyjwt>=2.12.1", - "python-json-logger>=4.1.0", + "python-json-logger>=4.0.0", "python-multipart>=0.0.22", # Transitive pin (mcp, fastapi); dependabot/81 "PyYAML>=6.0.3", "requests==2.33.0", # Transivite pin (requests-oauthlib) "requests-oauthlib>=2.0.0", - "sqlalchemy>=2.0.49", - "sse-starlette>=3.3.4", - "starlette>=0.52.1", # prometheus-fastapi-instrumentator 7.1.0 caps at <1.0.0 + "sqlalchemy>=2.0.48", + "sse-starlette>=3.3.3", + "starlette>=0.52.1", "starlette-compress>=1.7.0", "typer>=0.24.1", "urllib3>=2.6.3", # Transitive pin (requests); dependabot/76 @@ -172,12 +172,12 @@ dev = [ # Redis with hiredis C parser for up to 83x faster response parsing # See ADR-026 for benchmarks and decision rationale redis = [ - "redis[hiredis]>=7.4.0", + "redis[hiredis]>=7.3.0", ] # Pure-Python Redis parser fallback (for environments where hiredis wheels aren't available) redis-pure = [ - "redis>=7.4.0", + "redis>=7.3.0", ] # PostgreSQL adapter with psycopg3 @@ -191,8 +191,8 @@ llmchat = [ "langchain-core>=1.2.28", "langchain-mcp-adapters>=0.2.2", "langchain-ollama>=1.0.1", - "langchain-openai>=1.1.12", - "langgraph>=1.1.6", + "langchain-openai>=1.1.11", + "langgraph>=1.1.2", ] # Fuzzing and property-based testing @@ -213,6 +213,7 @@ observability = [ "opentelemetry-api>=1.40.0", "opentelemetry-exporter-otlp-proto-grpc>=1.40.0", "opentelemetry-exporter-otlp-proto-http>=1.40.0", + "opentelemetry-instrumentation-httpx>=0.52b0", "opentelemetry-sdk>=1.40.0", ] @@ -252,9 +253,9 @@ templating = [ # External plugin packages (optional) # Install with: pip install mcp-contextforge-gateway[plugins] plugins = [ + "cpex-rate-limiter>=0.0.3", "cpex-encoded-exfil-detection>=0.2.0", "cpex-pii-filter>=0.2.0", - "cpex-rate-limiter>=0.0.3", "cpex-retry-with-backoff>=0.1.0", "cpex-secrets-detection>=0.1.0", "cpex-url-reputation>=0.1.1", @@ -263,10 +264,10 @@ plugins = [ # gRPC Support (EXPERIMENTAL - optional, disabled by default) # Install with: pip install mcp-contextforge-gateway[grpc] grpc = [ - "grpcio>=1.80.0", - "grpcio-reflection>=1.80.0", - "grpcio-tools>=1.80.0", - "protobuf>=5.29.0,<7.0.0", # grpcio-reflection 1.78.x caps at <7.0.0 + "grpcio>=1.78.0", + "grpcio-reflection>=1.78.0", + "grpcio-tools>=1.78.0", + "protobuf>=5.29.0,<7.0.0", # grpcio-reflection 1.78.x caps at <7.0.0 ] # UI Testing diff --git a/tests/unit/js/tools.test.js b/tests/unit/js/tools.test.js index f4c2b971eb..27cfd6fab8 100644 --- a/tests/unit/js/tools.test.js +++ b/tests/unit/js/tools.test.js @@ -18,10 +18,6 @@ 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"; @@ -1772,8 +1768,7 @@ describe("runToolTest - additional", () => { ); await runToolTest(); - // When button is disabled, runToolTest returns silently without making a request - expect(fetchWithTimeout).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith("Tool test already running"); consoleSpy.mockRestore(); }); @@ -2194,7 +2189,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.arguments[paramKey]).toEqual(expected); + expect(body.params[paramKey]).toEqual(expected); consoleSpy.mockRestore(); }); @@ -2249,7 +2244,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.arguments[paramKey]).toEqual(expected); + expect(body.params[paramKey]).toEqual(expected); consoleSpy.mockRestore(); }); @@ -3118,445 +3113,3 @@ 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 3b6a94c8e3..1da14e8c48 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 (no mappings = signed URL mode): + 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 preserved in URL for POST (signed URL support) - - Remaining payload goes to the JSON body (without query params) + - 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" @@ -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 (no mappings = signed URL support): + # 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 STAY in URL (not merged into body) for signed URL support + # 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?api_key=secret123&version=v2", # Path param substituted, query params preserved - json={"title": "New Post", "content": "Hello World"}, # Only body params + "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, ) @@ -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 preserved in URL for signed URL support (no mappings) + # 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?version=v2", - json={"name": "updated"}, + "http://example.com/api/items/1", + json={"name": "updated", "version": "v2"}, headers=mock_tool.headers, ) @@ -4335,12 +4335,10 @@ 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"}, # Only input args, URL query params stay in URL + {"query": "test", "existing": "1"}, {"Content-Type": "application/json"}, ), ( @@ -4355,11 +4353,10 @@ 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"}, # Only input args, URL query params stay in URL + {"query": "test", "existing": "1"}, {"Content-Type": "application/json"}, ), ], @@ -4399,27 +4396,12 @@ 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) - # 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, - ) + 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): @@ -4869,519 +4851,6 @@ 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)."""