Skip to content

Commit 2b6a68a

Browse files
rakduttagcgoncalvesbrian-hussey
authored
fix[bug]: handle non-JSON responses and query params in REST tools (#3855, #3857) (#3873)
* fix: handle non-JSON responses and query params in REST tools (#3855, #3857) Backend: - Add _handle_json_parse_error() for graceful fallback when REST APIs return HTML, plain text, XML, or responses with encoding issues - Catch json.JSONDecodeError, orjson.JSONDecodeError, UnicodeDecodeError, and AttributeError during response parsing (httpx uses stdlib json, not orjson) - Add configurable REST_RESPONSE_TEXT_MAX_LENGTH (default: 5000, range: 1000-100000) to truncate non-JSON response text and limit sensitive data exposure - Fix query param handling: GET requests merge URL params with input args (URL params take precedence on conflicts); POST/PUT/PATCH/DELETE preserve query params in URL for signed URL support (Azure SAS, AWS presigned, etc.) - Add jq filter validation to detect email addresses mistakenly used as filters Frontend (admin_ui): - Fix runToolTest/runToolValidation to use tools/call JSON-RPC method instead of tool name as method (MCP protocol compliance) - Add invokeTool() function for "Invoke" button in Tools table - Port all JS changes to modular admin_ui/ structure (Vite bundled) Closes #3855 Closes #3857 Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * export funcyion in tools.js Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com> * pre-commit Signed-off-by: Gabriel Costa <gabrielcg@proton.me> * resolve rebase conflicts - merge mapping features with signed URL support Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com> * rebase conflict issue Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com> * Update .secrets.baseline Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com> --------- Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Signed-off-by: Gabriel Costa <gabrielcg@proton.me> Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com> Co-authored-by: Gabriel Costa <gabrielcg@proton.me> Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
1 parent a02a04b commit 2b6a68a

8 files changed

Lines changed: 1335 additions & 70 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,11 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
21362136
# TOOL_CONCURRENT_LIMIT=10
21372137
# GATEWAY_TOOL_NAME_SEPARATOR=-
21382138

2139+
# Maximum length of response text returned for non-JSON REST API responses
2140+
# Longer responses are truncated to prevent exposing excessive sensitive data
2141+
# Default: 5000 characters, Range: 1000-100000
2142+
# REST_RESPONSE_TEXT_MAX_LENGTH=5000
2143+
21392144
# Prompt Configuration
21402145
# PROMPT_CACHE_SIZE=100
21412146
# MAX_PROMPT_SIZE=102400

.secrets.baseline

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"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$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-14T13:09:46Z",
6+
"generated_at": "2026-04-14T14:08:10Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -4830,7 +4830,7 @@
48304830
"hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff",
48314831
"is_secret": false,
48324832
"is_verified": false,
4833-
"line_number": 2228,
4833+
"line_number": 2236,
48344834
"type": "Secret Keyword",
48354835
"verified_result": null
48364836
}
@@ -8624,39 +8624,39 @@
86248624
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
86258625
"is_secret": false,
86268626
"is_verified": false,
8627-
"line_number": 6376,
8627+
"line_number": 6907,
86288628
"type": "Secret Keyword",
86298629
"verified_result": null
86308630
},
86318631
{
86328632
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
86338633
"is_secret": false,
86348634
"is_verified": false,
8635-
"line_number": 6868,
8635+
"line_number": 7399,
86368636
"type": "Secret Keyword",
86378637
"verified_result": null
86388638
},
86398639
{
86408640
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
86418641
"is_secret": false,
86428642
"is_verified": false,
8643-
"line_number": 8215,
8643+
"line_number": 8746,
86448644
"type": "Secret Keyword",
86458645
"verified_result": null
86468646
},
86478647
{
86488648
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
86498649
"is_secret": false,
86508650
"is_verified": false,
8651-
"line_number": 8357,
8651+
"line_number": 8888,
86528652
"type": "Secret Keyword",
86538653
"verified_result": null
86548654
},
86558655
{
86568656
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
86578657
"is_secret": false,
86588658
"is_verified": false,
8659-
"line_number": 8733,
8659+
"line_number": 9264,
86608660
"type": "Secret Keyword",
86618661
"verified_result": null
86628662
}

mcpgateway/admin_ui/admin.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ Admin.showUsageStatsModal = showUsageStatsModal;
485485
import {
486486
editTool,
487487
initToolSelect,
488+
invokeTool,
488489
testTool,
489490
enrichTool,
490491
generateToolTestCases,
@@ -496,6 +497,7 @@ import {
496497

497498
Admin.editTool = editTool;
498499
Admin.initToolSelect = initToolSelect;
500+
Admin.invokeTool = invokeTool;
499501
Admin.testTool = testTool;
500502
Admin.enrichTool = enrichTool;
501503
Admin.generateToolTestCases = generateToolTestCases;

mcpgateway/admin_ui/tools.js

Lines changed: 193 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2916,8 +2916,11 @@ export const runToolValidation = async function (testIndex) {
29162916
const payload = {
29172917
jsonrpc: "2.0",
29182918
id: Date.now(),
2919-
method: AppState.currentTestTool.name,
2920-
params,
2919+
method: "tools/call",
2920+
params: {
2921+
name: AppState.currentTestTool.name,
2922+
arguments: params,
2923+
},
29212924
};
29222925

29232926
// Parse custom headers from the passthrough headers field
@@ -3199,14 +3202,16 @@ export const runToolTest = async function () {
31993202
const runButton = document.querySelector('button[onclick="runToolTest()"]');
32003203

32013204
if (!form || !AppState.currentTestTool) {
3202-
console.error("Tool test form or current tool not found");
3205+
console.error("Tool test form or current tool not found", {
3206+
form: !!form,
3207+
currentTestTool: AppState.currentTestTool,
3208+
});
32033209
showErrorMessage("Tool test form not available");
32043210
return;
32053211
}
32063212

32073213
// Prevent multiple concurrent test runs
32083214
if (runButton && runButton.disabled) {
3209-
console.log("Tool test already running");
32103215
return;
32113216
}
32123217

@@ -3329,8 +3334,11 @@ export const runToolTest = async function () {
33293334
const payload = {
33303335
jsonrpc: "2.0",
33313336
id: Date.now(),
3332-
method: AppState.currentTestTool.name,
3333-
params,
3337+
method: "tools/call",
3338+
params: {
3339+
name: AppState.currentTestTool.name,
3340+
arguments: params,
3341+
},
33343342
};
33353343

33363344
// Parse custom headers from the passthrough headers field
@@ -3502,3 +3510,182 @@ export const cleanupToolTestModal = function () {
35023510
console.error("Error cleaning up tool test modal:", error);
35033511
}
35043512
};
3513+
3514+
// ===================================================================
3515+
// TOOL INVOCATION (opens test modal by tool name)
3516+
// ===================================================================
3517+
3518+
/**
3519+
* Fetch tool details from the API by name.
3520+
* @param {string} toolName - The name of the tool to fetch
3521+
* @returns {Promise<Object>} The tool object
3522+
*/
3523+
export async function fetchToolDetails(toolName) {
3524+
const response = await fetchWithTimeout(
3525+
`${window.ROOT_PATH}/admin/tools/${encodeURIComponent(toolName)}`,
3526+
{
3527+
headers: {
3528+
Accept: "application/json",
3529+
"Content-Type": "application/json",
3530+
},
3531+
}
3532+
);
3533+
3534+
if (!response.ok) {
3535+
const errorText = await response.text();
3536+
throw new Error(
3537+
`Failed to fetch tool details (${response.status}): ${errorText}`
3538+
);
3539+
}
3540+
3541+
return await response.json();
3542+
}
3543+
3544+
/**
3545+
* Create a form input field based on schema.
3546+
* @param {string} key - The field name
3547+
* @param {Object} schema - The field schema
3548+
* @param {boolean} isRequired - Whether the field is required
3549+
* @returns {HTMLElement} The input element
3550+
*/
3551+
export function createFormInput(key, schema, isRequired) {
3552+
let input;
3553+
const baseInputClass =
3554+
"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";
3555+
3556+
if (schema.enum) {
3557+
input = document.createElement("select");
3558+
input.className = baseInputClass;
3559+
schema.enum.forEach((option) => {
3560+
const opt = document.createElement("option");
3561+
opt.value = option;
3562+
opt.textContent = option;
3563+
if (option === schema.default) {
3564+
opt.selected = true;
3565+
}
3566+
input.appendChild(opt);
3567+
});
3568+
} else if (schema.type === "boolean") {
3569+
input = document.createElement("input");
3570+
input.type = "checkbox";
3571+
input.className =
3572+
"h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded dark:bg-gray-700";
3573+
if (schema.default === true) {
3574+
input.checked = true;
3575+
}
3576+
} else if (schema.type === "number" || schema.type === "integer") {
3577+
input = document.createElement("input");
3578+
input.type = "number";
3579+
input.className = baseInputClass;
3580+
if (schema.default !== undefined) {
3581+
input.value = schema.default;
3582+
}
3583+
} else {
3584+
input = document.createElement("input");
3585+
input.type = "text";
3586+
input.className = baseInputClass;
3587+
if (schema.default !== undefined) {
3588+
input.value = schema.default;
3589+
}
3590+
}
3591+
3592+
input.name = key;
3593+
if (isRequired) {
3594+
input.required = true;
3595+
}
3596+
3597+
return input;
3598+
}
3599+
3600+
/**
3601+
* Generate form fields from tool input schema.
3602+
* @param {Object} tool - The tool object with input_schema
3603+
*/
3604+
export function generateToolFormFields(tool) {
3605+
const formFields = safeGetElement("tool-test-form-fields");
3606+
if (!formFields) return;
3607+
3608+
// Clear existing fields safely
3609+
while (formFields.firstChild) {
3610+
formFields.removeChild(formFields.firstChild);
3611+
}
3612+
3613+
if (!tool.input_schema || !tool.input_schema.properties) {
3614+
const noParams = document.createElement("p");
3615+
noParams.className = "text-sm text-gray-500 dark:text-gray-400";
3616+
noParams.textContent = "This tool has no input parameters.";
3617+
formFields.appendChild(noParams);
3618+
return;
3619+
}
3620+
3621+
const properties = tool.input_schema.properties;
3622+
const required = tool.input_schema.required || [];
3623+
3624+
for (const [key, schema] of Object.entries(properties)) {
3625+
const isRequired = required.includes(key);
3626+
const fieldDiv = document.createElement("div");
3627+
3628+
const label = document.createElement("label");
3629+
label.className =
3630+
"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
3631+
label.textContent = `${key}${isRequired ? " *" : ""}`;
3632+
fieldDiv.appendChild(label);
3633+
3634+
if (schema.description) {
3635+
const desc = document.createElement("p");
3636+
desc.className = "text-xs text-gray-500 dark:text-gray-400 mb-1";
3637+
desc.textContent = schema.description;
3638+
fieldDiv.appendChild(desc);
3639+
}
3640+
3641+
const input = createFormInput(key, schema, isRequired);
3642+
fieldDiv.appendChild(input);
3643+
formFields.appendChild(fieldDiv);
3644+
}
3645+
}
3646+
3647+
/**
3648+
* Open tool test modal and fetch tool details from API.
3649+
* Called by the "Invoke" button in the Tools table.
3650+
* @param {string} toolName - The name of the tool to test
3651+
*/
3652+
export const invokeTool = async function (toolName) {
3653+
try {
3654+
const tool = await fetchToolDetails(toolName);
3655+
3656+
// Store tool details in AppState for runToolTest to access
3657+
AppState.currentTestTool = tool;
3658+
3659+
// Populate modal title and description
3660+
const titleEl = safeGetElement("tool-test-modal-title");
3661+
const descEl = safeGetElement("tool-test-modal-description");
3662+
if (titleEl) {
3663+
titleEl.textContent = `Test Tool: ${tool.displayName || tool.name}`;
3664+
}
3665+
if (descEl) {
3666+
descEl.textContent = tool.description || "";
3667+
}
3668+
3669+
// Clear previous results
3670+
const resultContainer = safeGetElement("tool-test-result");
3671+
if (resultContainer) {
3672+
resultContainer.textContent = "";
3673+
}
3674+
3675+
// Show the modal
3676+
const modal = safeGetElement("tool-test-modal");
3677+
if (modal) {
3678+
modal.classList.remove("hidden");
3679+
}
3680+
3681+
// Generate form fields based on input schema
3682+
if (typeof window.renderToolTestForm === "function") {
3683+
window.renderToolTestForm(tool);
3684+
} else {
3685+
generateToolFormFields(tool);
3686+
}
3687+
} catch (error) {
3688+
console.error("Error invoking tool:", error);
3689+
showErrorMessage("Failed to open tool test modal: " + error.message);
3690+
}
3691+
};

mcpgateway/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,14 @@ def parse_issuers(cls, v: Any) -> list[str]:
16031603
max_tool_retries: int = 3
16041604
tool_rate_limit: int = 100 # requests per minute
16051605
tool_concurrent_limit: int = 10
1606+
rest_response_text_max_length: int = Field(
1607+
default=5000,
1608+
ge=1000,
1609+
le=100000,
1610+
description="Maximum length of response text to return for non-JSON REST API responses. "
1611+
"Longer responses are truncated to prevent exposing excessive sensitive data. "
1612+
"Default: 5000 characters. Range: 1000-100000.",
1613+
)
16061614

16071615
# Content Security - Size Limits
16081616
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

0 commit comments

Comments
 (0)