Skip to content

Commit 41a08b7

Browse files
rakduttarakdutta1
andauthored
Improved Error for Add tool and edit Tool (#569)
* 363 tool Signed-off-by: RAKHI DUTTA <[email protected]> * tool 363 Signed-off-by: RAKHI DUTTA <[email protected]> * 363_tool Signed-off-by: RAKHI DUTTA <[email protected]> * ToolUpdate Signed-off-by: RAKHI DUTTA <[email protected]> * revert test_tool_service with main version Signed-off-by: RAKHI DUTTA <[email protected]> * admin.js changes for edit_tool Signed-off-by: RAKHI DUTTA <[email protected]> * update test_admin Signed-off-by: RAKHI DUTTA <[email protected]> * flake8 error fix Signed-off-by: RAKHI DUTTA <[email protected]> * test cases update Signed-off-by: RAKHI DUTTA <[email protected]> --------- Signed-off-by: RAKHI DUTTA <[email protected]> Co-authored-by: RAKHI DUTTA <[email protected]>
1 parent 2c908e5 commit 41a08b7

File tree

9 files changed

+283
-162
lines changed

9 files changed

+283
-162
lines changed

mcpgateway/admin.py

Lines changed: 156 additions & 99 deletions
Large diffs are not rendered by default.

mcpgateway/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,6 @@ def validate_database(self) -> None:
493493
validation_dangerous_html_pattern: str = (
494494
r"<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\b|</*(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)>"
495495
)
496-
497496
validation_dangerous_js_pattern: str = r"(?i)(?:^|\s|[\"'`<>=])(javascript:|vbscript:|data:\s*[^,]*[;\s]*(javascript|vbscript)|\bon[a-z]+\s*=|<\s*script\b)"
498497

499498
validation_allowed_url_schemes: List[str] = ["http://", "https://", "ws://", "wss://"]

mcpgateway/schemas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,8 @@ class ToolUpdate(BaseModelWithConfigDict):
550550
name: Optional[str] = Field(None, description="Unique name for the tool")
551551
url: Optional[Union[str, AnyHttpUrl]] = Field(None, description="Tool endpoint URL")
552552
description: Optional[str] = Field(None, description="Tool description")
553-
request_type: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH", "SSE", "STDIO", "STREAMABLEHTTP"]] = Field(None, description="HTTP method to be used for invoking the tool")
554553
integration_type: Optional[Literal["MCP", "REST"]] = Field(None, description="Tool integration type")
554+
request_type: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH", "SSE", "STDIO", "STREAMABLEHTTP"]] = Field(None, description="HTTP method to be used for invoking the tool")
555555
headers: Optional[Dict[str, str]] = Field(None, description="Additional headers to send when invoking the tool")
556556
input_schema: Optional[Dict[str, Any]] = Field(None, description="JSON Schema for validating tool parameters")
557557
annotations: Optional[Dict[str, Any]] = Field(None, description="Tool annotations for behavior hints")
@@ -645,8 +645,8 @@ def validate_request_type(cls, v: str, values: Dict[str, Any]) -> str:
645645
Raises:
646646
ValueError: When value is unsafe
647647
"""
648-
integration_type = values.config.get("integration_type", "MCP")
649648

649+
integration_type = values.data.get("integration_type", "MCP")
650650
if integration_type == "MCP":
651651
allowed = ["SSE", "STREAMABLEHTTP", "STDIO"]
652652
else: # REST

mcpgateway/services/tool_service.py

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead:
269269
Created tool information.
270270
271271
Raises:
272-
ToolNameConflictError: If tool name already exists.
272+
IntegrityError: If there is a database integrity error.
273273
ToolError: For other tool registration errors.
274274
275275
Examples:
@@ -296,17 +296,6 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead:
296296
'tool_read'
297297
"""
298298
try:
299-
if not tool.gateway_id:
300-
existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name)).scalar_one_or_none()
301-
else:
302-
existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name).where(DbTool.gateway_id == tool.gateway_id)).scalar_one_or_none() # pylint: disable=comparison-with-callable
303-
if existing_tool:
304-
raise ToolNameConflictError(
305-
existing_tool.name,
306-
enabled=existing_tool.enabled,
307-
tool_id=existing_tool.id,
308-
)
309-
310299
if tool.auth is None:
311300
auth_type = None
312301
auth_value = None
@@ -335,12 +324,11 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead:
335324
await self._notify_tool_added(db_tool)
336325
logger.info(f"Registered tool: {db_tool.name}")
337326
return self._convert_tool_to_read(db_tool)
338-
except IntegrityError:
339-
db.rollback()
340-
raise ToolError(f"Tool already exists: {tool.name}")
341-
except Exception as e:
342-
db.rollback()
343-
raise ToolError(f"Failed to register tool: {str(e)}")
327+
except IntegrityError as ie:
328+
logger.error(f"IntegrityError during tool registration: {ie}")
329+
raise ie
330+
except Exception as ex:
331+
raise ToolError(f"Failed to register tool: {str(ex)}")
344332

345333
async def list_tools(self, db: Session, include_inactive: bool = False, cursor: Optional[str] = None) -> List[ToolRead]:
346334
"""
@@ -719,7 +707,7 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate)
719707
720708
Raises:
721709
ToolNotFoundError: If the tool is not found.
722-
ToolNameConflictError: If a new name conflicts with an existing tool.
710+
IntegrityError: If there is a database integrity error.
723711
ToolError: For other update errors.
724712
725713
Examples:
@@ -744,16 +732,6 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate)
744732
tool = db.get(DbTool, tool_id)
745733
if not tool:
746734
raise ToolNotFoundError(f"Tool not found: {tool_id}")
747-
if tool_update.name is not None and not (tool_update.name == tool.name and tool_update.gateway_id == tool.gateway_id):
748-
# pylint: disable=comparison-with-callable
749-
existing_tool = db.execute(select(DbTool).where(DbTool.name == tool_update.name).where(DbTool.gateway_id == tool_update.gateway_id).where(DbTool.id != tool_id)).scalar_one_or_none()
750-
if existing_tool:
751-
raise ToolNameConflictError(
752-
tool_update.name,
753-
enabled=existing_tool.enabled,
754-
tool_id=existing_tool.id,
755-
)
756-
757735
if tool_update.name is not None:
758736
tool.name = tool_update.name
759737
if tool_update.url is not None:
@@ -787,9 +765,12 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate)
787765
await self._notify_tool_updated(tool)
788766
logger.info(f"Updated tool: {tool.name}")
789767
return self._convert_tool_to_read(tool)
790-
except Exception as e:
768+
except IntegrityError as ie:
769+
logger.error(f"IntegrityError during tool update: {ie}")
770+
raise ie
771+
except Exception as ex:
791772
db.rollback()
792-
raise ToolError(f"Failed to update tool: {str(e)}")
773+
raise ToolError(f"Failed to update tool: {str(ex)}")
793774

794775
async def _notify_tool_updated(self, tool: DbTool) -> None:
795776
"""

mcpgateway/static/admin.js

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,7 @@ async function editTool(toolId) {
14191419
);
14201420

14211421
if (!response.ok) {
1422+
// If the response is not OK, throw an error
14221423
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
14231424
}
14241425

@@ -4194,7 +4195,7 @@ async function handleResourceFormSubmit(e) {
41944195
const form = e.target;
41954196
const formData = new FormData(form);
41964197
const status = safeGetElement("status-resources");
4197-
const loading = safeGetElement("add-gateway-loading");
4198+
const loading = safeGetElement("add-resource-loading");
41984199
try {
41994200
// Validate inputs
42004201
const name = formData.get("name");
@@ -4391,6 +4392,61 @@ async function handleToolFormSubmit(event) {
43914392
showErrorMessage(error.message);
43924393
}
43934394
}
4395+
async function handleEditToolFormSubmit(event) {
4396+
event.preventDefault();
4397+
4398+
const form = event.target;
4399+
4400+
try {
4401+
const formData = new FormData(form);
4402+
4403+
// Basic validation (customize as needed)
4404+
const name = formData.get("name");
4405+
const url = formData.get("url");
4406+
const nameValidation = validateInputName(name, "tool");
4407+
const urlValidation = validateUrl(url);
4408+
4409+
if (!nameValidation.valid) {
4410+
throw new Error(nameValidation.error);
4411+
}
4412+
if (!urlValidation.valid) {
4413+
throw new Error(urlValidation.error);
4414+
}
4415+
4416+
// // Save CodeMirror editors' contents if present
4417+
4418+
if (window.editToolHeadersEditor) window.editToolHeadersEditor.save();
4419+
if (window.editToolSchemaEditor) window.editToolSchemaEditor.save();
4420+
4421+
const isInactiveCheckedBool = isInactiveChecked("tools");
4422+
formData.append("is_inactive_checked", isInactiveCheckedBool);
4423+
4424+
// Submit via fetch
4425+
const response = await fetch(form.action, {
4426+
method: "POST",
4427+
body: formData,
4428+
headers: { "X-Requested-With": "XMLHttpRequest" }
4429+
});
4430+
console.log("response:", response);
4431+
result = await response.json();
4432+
console.log("result edit tool form:", result);
4433+
if (!result.success) {
4434+
throw new Error(result.message || "An error occurred");
4435+
}
4436+
else {
4437+
const redirectUrl = isInactiveCheckedBool
4438+
? `${window.ROOT_PATH}/admin?include_inactive=true#tools`
4439+
: `${window.ROOT_PATH}/admin#tools`;
4440+
window.location.href = redirectUrl;
4441+
}
4442+
4443+
} catch (error) {
4444+
console.error("Fetch error:", error);
4445+
showErrorMessage(error.message);
4446+
}
4447+
}
4448+
4449+
43944450

43954451
// ===================================================================
43964452
// ENHANCED FORM VALIDATION for All Forms
@@ -4925,6 +4981,16 @@ function setupFormHandlers() {
49254981
}
49264982
});
49274983
}
4984+
const editToolForm = safeGetElement("edit-tool-form");
4985+
if (editToolForm) {
4986+
editToolForm.addEventListener("submit", handleEditToolFormSubmit);
4987+
editToolForm.addEventListener("click", () => {
4988+
if (getComputedStyle(editToolForm).display !== "none") {
4989+
refreshEditors();
4990+
}
4991+
});
4992+
}
4993+
49284994
}
49294995

49304996
function setupSchemaModeHandlers() {

tests/e2e/test_admin_apis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ async def test_admin_tool_name_conflict(self, client: AsyncClient, mock_settings
286286

287287
# Try to create duplicate
288288
response = await client.post("/admin/tools/", data=form_data, headers=TEST_AUTH_HEADER)
289-
assert response.status_code in [400, 500] # Could be either
289+
assert response.status_code in [400, 409, 500] # Could be either
290290
assert response.json()["success"] is False
291291

292292

tests/e2e/test_main_apis.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,8 +615,8 @@ async def test_tool_name_conflict(self, client: AsyncClient, mock_auth):
615615

616616
# Try to create duplicate - might succeed with different ID
617617
response = await client.post("/tools", json=tool_data, headers=TEST_AUTH_HEADER)
618-
# Either succeeds (200) or conflicts (409)
619-
assert response.status_code in [200, 400]
618+
# Accept 409 Conflict as valid for duplicate
619+
assert response.status_code in [200, 400, 409]
620620
if response.status_code == 400:
621621
assert "already exists" in response.json()["detail"]
622622

tests/unit/mcpgateway/services/test_tool_service.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,13 @@ async def test_register_tool_with_gateway_id(self, tool_service, mock_tool, test
277277
gateway_id="1",
278278
)
279279

280-
# Should raise ToolError wrapping ToolNameConflictError
280+
# Should raise ToolError due to missing slug on NoneType
281281
with pytest.raises(ToolError) as exc_info:
282282
await tool_service.register_tool(test_db, tool_create)
283283

284284
# The service wraps exceptions, so check the message
285-
assert "Tool already exists with name" in str(exc_info.value)
285+
assert "Failed to register tool" in str(exc_info.value)
286+
assert "slug" in str(exc_info.value)
286287

287288
@pytest.mark.asyncio
288289
async def test_register_tool_with_none_auth(self, tool_service, test_db):
@@ -323,12 +324,13 @@ async def test_register_tool_name_conflict(self, tool_service, mock_tool, test_d
323324
request_type="SSE",
324325
)
325326

326-
# Should raise ToolError wrapping ToolNameConflictError
327-
with pytest.raises(ToolError) as exc_info:
327+
# Should raise IntegrityError due to UNIQUE constraint failure
328+
test_db.commit = Mock(side_effect=IntegrityError("UNIQUE constraint failed: tools.name", None, None))
329+
with pytest.raises(IntegrityError) as exc_info:
328330
await tool_service.register_tool(test_db, tool_create)
329331

330-
# The service wraps exceptions, so check the message
331-
assert "Tool already exists with name" in str(exc_info.value)
332+
# Check the error message for UNIQUE constraint failure
333+
assert "UNIQUE constraint failed: tools.name" in str(exc_info.value)
332334

333335
@pytest.mark.asyncio
334336
async def test_register_inactive_tool_name_conflict(self, tool_service, mock_tool, test_db):
@@ -348,12 +350,13 @@ async def test_register_inactive_tool_name_conflict(self, tool_service, mock_too
348350
request_type="SSE",
349351
)
350352

351-
# Should raise ToolError wrapping ToolNameConflictError
352-
with pytest.raises(ToolError) as exc_info:
353+
# Should raise IntegrityError due to UNIQUE constraint failure
354+
test_db.commit = Mock(side_effect=IntegrityError("UNIQUE constraint failed: tools.name", None, None))
355+
with pytest.raises(IntegrityError) as exc_info:
353356
await tool_service.register_tool(test_db, tool_create)
354357

355-
# The service wraps exceptions, so check the message
356-
assert "(currently inactive, ID:" in str(exc_info.value)
358+
# Check the error message for UNIQUE constraint failure
359+
assert "UNIQUE constraint failed: tools.name" in str(exc_info.value)
357360

358361
@pytest.mark.asyncio
359362
async def test_register_tool_db_integrity_error(self, tool_service, test_db):
@@ -375,12 +378,13 @@ async def test_register_tool_db_integrity_error(self, tool_service, test_db):
375378
request_type="SSE",
376379
)
377380

378-
# Should raise ToolError
379-
with pytest.raises(ToolError) as exc_info:
381+
# Should raise IntegrityError
382+
with pytest.raises(IntegrityError) as exc_info:
380383
await tool_service.register_tool(test_db, tool_create)
384+
# After exception, rollback should be called
385+
test_db.rollback.assert_called_once()
381386

382-
assert "Tool already exists" in str(exc_info.value)
383-
test_db.rollback.assert_called_once()
387+
assert "orig" in str(exc_info.value)
384388

385389
@pytest.mark.asyncio
386390
async def test_list_tools(self, tool_service, mock_tool, test_db):
@@ -966,23 +970,25 @@ async def test_update_tool_name_conflict(self, tool_service, mock_tool, test_db)
966970
conflicting_tool.name = "existing_tool"
967971
conflicting_tool.enabled = True
968972

969-
# Mock DB query to check for name conflicts (returns the conflicting tool)
973+
# Mock DB query to check for name conflicts (returns None, so no pre-check conflict)
970974
mock_scalar = Mock()
971-
mock_scalar.scalar_one_or_none.return_value = conflicting_tool
975+
mock_scalar.scalar_one_or_none.return_value = None
972976
test_db.execute = Mock(return_value=mock_scalar)
973977

978+
# Mock commit to raise IntegrityError
979+
test_db.commit = Mock(side_effect=IntegrityError("statement", "params", "orig"))
974980
test_db.rollback = Mock()
975981

976982
# Create update request with conflicting name
977983
tool_update = ToolUpdate(
978984
name="existing_tool", # Name that conflicts with another tool
979985
)
980986

981-
# The service wraps the exception in ToolError
982-
with pytest.raises(ToolError) as exc_info:
987+
# Should raise IntegrityError for name conflict during commit
988+
with pytest.raises(IntegrityError) as exc_info:
983989
await tool_service.update_tool(test_db, 1, tool_update)
984990

985-
assert "Tool already exists with name" in str(exc_info.value)
991+
assert "statement" in str(exc_info.value)
986992

987993
@pytest.mark.asyncio
988994
async def test_update_tool_not_found(self, tool_service, test_db):

tests/unit/mcpgateway/test_admin.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,20 +417,27 @@ async def test_admin_edit_tool_all_error_paths(self, mock_update_tool, mock_requ
417417
"""Test editing tool with all possible error paths."""
418418
tool_id = "tool-1"
419419

420-
# Test ToolNameConflictError
421-
mock_update_tool.side_effect = ToolNameConflictError("Name already exists")
420+
# IntegrityError should return 409 with JSON body
421+
# Third-Party
422+
from sqlalchemy.exc import IntegrityError
423+
424+
mock_update_tool.side_effect = IntegrityError("Integrity constraint", {}, Exception("Duplicate key"))
422425
result = await admin_edit_tool(tool_id, mock_request, mock_db, "test-user")
423-
assert result.status_code == 400
426+
assert result.status_code == 409
424427

425-
# Test ToolError
428+
# ToolError should return 500 with JSON body
426429
mock_update_tool.side_effect = ToolError("Tool configuration error")
427430
result = await admin_edit_tool(tool_id, mock_request, mock_db, "test-user")
428431
assert result.status_code == 500
432+
data = result.body
433+
assert b"Tool configuration error" in data
429434

430-
# Test generic exception
435+
# Generic Exception should return 500 with JSON body
431436
mock_update_tool.side_effect = Exception("Unexpected error")
432437
result = await admin_edit_tool(tool_id, mock_request, mock_db, "test-user")
433438
assert result.status_code == 500
439+
data = result.body
440+
assert b"Unexpected error" in data
434441

435442
@patch.object(ToolService, "update_tool")
436443
async def test_admin_edit_tool_with_empty_optional_fields(self, mock_update_tool, mock_request, mock_db):
@@ -451,7 +458,12 @@ async def test_admin_edit_tool_with_empty_optional_fields(self, mock_update_tool
451458

452459
result = await admin_edit_tool("tool-1", mock_request, mock_db, "test-user")
453460

454-
assert isinstance(result, RedirectResponse)
461+
# Validate response type and content
462+
assert isinstance(result, JSONResponse)
463+
assert result.status_code == 200
464+
payload = json.loads(result.body.decode())
465+
assert payload["success"] is True
466+
assert payload["message"] == "Edit tool successfully"
455467

456468
# Verify empty strings are handled correctly
457469
call_args = mock_update_tool.call_args[0]

0 commit comments

Comments
 (0)