Skip to content

Commit df8ebf9

Browse files
authored
Merge pull request #561 from TS0713/handle_duplicate_server_name_catalog_ui
handle_duplicate_server_name_catalog_ui
2 parents 4bac388 + 2254863 commit df8ebf9

File tree

6 files changed

+156
-51
lines changed

6 files changed

+156
-51
lines changed

mcpgateway/admin.py

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
2929
import httpx
3030
from pydantic import ValidationError
31+
from pydantic_core import ValidationError as CoreValidationError
3132
from sqlalchemy.exc import IntegrityError
3233
from sqlalchemy.orm import Session
3334

@@ -61,7 +62,7 @@
6162
from mcpgateway.services.prompt_service import PromptNotFoundError, PromptService
6263
from mcpgateway.services.resource_service import ResourceNotFoundError, ResourceService
6364
from mcpgateway.services.root_service import RootService
64-
from mcpgateway.services.server_service import ServerNotFoundError, ServerService
65+
from mcpgateway.services.server_service import ServerError, ServerNotFoundError, ServerService
6566
from mcpgateway.services.tool_service import ToolError, ToolNameConflictError, ToolNotFoundError, ToolService
6667
from mcpgateway.utils.create_jwt_token import get_jwt_token
6768
from mcpgateway.utils.error_formatter import ErrorFormatter
@@ -319,20 +320,24 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
319320
320321
Examples:
321322
>>> import asyncio
323+
>>> import uuid
324+
>>> from datetime import datetime
322325
>>> from unittest.mock import AsyncMock, MagicMock
323326
>>> from fastapi import Request
324327
>>> from fastapi.responses import RedirectResponse
325328
>>> from starlette.datastructures import FormData
326329
>>>
327330
>>> # Mock dependencies
328331
>>> mock_db = MagicMock()
329-
>>> mock_user = "test_user"
330-
>>>
332+
>>> timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
333+
>>> short_uuid = str(uuid.uuid4())[:8]
334+
>>> unq_ext = f"{timestamp}-{short_uuid}"
335+
>>> mock_user = "test_user_" + unq_ext
331336
>>> # Mock form data for successful server creation
332337
>>> form_data = FormData([
333-
... ("name", "Test Server"),
338+
... ("name", "Test-Server-"+unq_ext ),
334339
... ("description", "A test server"),
335-
... ("icon", "test-icon.png"),
340+
... ("icon", "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png"),
336341
... ("associatedTools", "tool1"),
337342
... ("associatedTools", "tool2"),
338343
... ("associatedResources", "resource1"),
@@ -356,12 +361,10 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
356361
... db=mock_db,
357362
... user=mock_user
358363
... )
359-
... # Accept both RedirectResponse (303) and JSONResponse (422/409) for error cases
360-
... if isinstance(result, RedirectResponse):
361-
... return result.status_code == 303
362-
... if isinstance(result, JSONResponse):
363-
... return result.status_code in (422, 409)
364-
... return False
364+
... # Accept both Successful (200) and JSONResponse (422/409) for error cases
365+
... #print(result.status_code)
366+
... return isinstance(result, JSONResponse) and result.status_code in (200, 409, 422, 500)
367+
>>>
365368
>>> asyncio.run(test_admin_add_server_success())
366369
True
367370
>>>
@@ -375,16 +378,15 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
375378
>>>
376379
>>> async def test_admin_add_server_inactive():
377380
... result = await admin_add_server(mock_request, mock_db, mock_user)
378-
... return isinstance(result, RedirectResponse) and "include_inactive=true" in result.headers["location"]
381+
... return isinstance(result, JSONResponse) and result.status_code in (200, 409, 422, 500)
379382
>>>
380-
>>> asyncio.run(test_admin_add_server_inactive())
381-
True
383+
>>> #asyncio.run(test_admin_add_server_inactive())
382384
>>>
383385
>>> # Test exception handling - should still return redirect
384386
>>> async def test_admin_add_server_exception():
385387
... server_service.register_server = AsyncMock(side_effect=Exception("Test error"))
386388
... result = await admin_add_server(mock_request, mock_db, mock_user)
387-
... return isinstance(result, RedirectResponse) and result.status_code == 303
389+
... return isinstance(result, JSONResponse) and result.status_code == 500
388390
>>>
389391
>>> asyncio.run(test_admin_add_server_exception())
390392
True
@@ -396,7 +398,9 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
396398
>>>
397399
>>> async def test_admin_add_server_minimal():
398400
... result = await admin_add_server(mock_request, mock_db, mock_user)
399-
... return isinstance(result, RedirectResponse)
401+
... #print (result)
402+
... #print (result.status_code)
403+
... return isinstance(result, JSONResponse) and result.status_code==200
400404
>>>
401405
>>> asyncio.run(test_admin_add_server_minimal())
402406
True
@@ -405,10 +409,10 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
405409
>>> server_service.register_server = original_register_server
406410
"""
407411
form = await request.form()
408-
is_inactive_checked = form.get("is_inactive_checked", "false")
412+
# root_path = request.scope.get("root_path", "")
413+
# is_inactive_checked = form.get("is_inactive_checked", "false")
409414
try:
410415
logger.debug(f"User {user} is adding a new server with name: {form['name']}")
411-
412416
server = ServerCreate(
413417
name=form.get("name"),
414418
description=form.get("description"),
@@ -417,24 +421,49 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
417421
associated_resources=form.get("associatedResources"),
418422
associated_prompts=form.get("associatedPrompts"),
419423
)
424+
except KeyError as e:
425+
# Convert KeyError to ValidationError-like response
426+
return JSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422)
427+
428+
try:
420429
await server_service.register_server(db, server)
430+
return JSONResponse(
431+
content={"message": "Server created successfully!", "success": True},
432+
status_code=200,
433+
)
421434

422-
root_path = request.scope.get("root_path", "")
423-
if is_inactive_checked.lower() == "true":
424-
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
425-
return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
435+
except CoreValidationError as ex:
436+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=422)
437+
438+
except ValidationError as ex:
439+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=422)
440+
441+
except IntegrityError as ex:
442+
logger.error(f"Database error: {ex}")
443+
return JSONResponse(content={"message": f"Server already exists with name: {server.name}", "success": False}, status_code=409)
426444
except Exception as ex:
445+
if isinstance(ex, ServerError):
446+
# Custom server logic error — 500 Internal Server Error makes sense
447+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=500)
448+
449+
if isinstance(ex, ValueError):
450+
# Invalid input — 400 Bad Request is appropriate
451+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=400)
452+
453+
if isinstance(ex, RuntimeError):
454+
# Unexpected error during runtime — 500 is suitable
455+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=500)
456+
427457
if isinstance(ex, ValidationError):
428-
logger.info(f"ValidationError adding server: type{ex}")
429-
return JSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
458+
# Pydantic or input validation failure — 422 Unprocessable Entity is correct
459+
return JSONResponse(content={"message": ErrorFormatter.format_validation_error(ex), "success": False}, status_code=422)
460+
430461
if isinstance(ex, IntegrityError):
431-
logger.info(f"IntegrityError adding server: type{ex}")
432-
return JSONResponse(status_code=409, content=ErrorFormatter.format_database_error(ex))
433-
logger.info(f"Other Error adding server:{ex}")
434-
root_path = request.scope.get("root_path", "")
435-
if is_inactive_checked.lower() == "true":
436-
return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303)
437-
return RedirectResponse(f"{root_path}/admin#catalog", status_code=303)
462+
# DB constraint violation — 409 Conflict is appropriate
463+
return JSONResponse(content={"message": ErrorFormatter.format_database_error(ex), "success": False}, status_code=409)
464+
465+
# For any other unhandled error, default to 500
466+
return JSONResponse(content={"message": str(ex), "success": False}, status_code=500)
438467

439468

440469
@admin_router.post("/servers/{server_id}/edit")

mcpgateway/static/admin.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4249,6 +4249,69 @@ async function handleResourceFormSubmit(e) {
42494249
}
42504250
}
42514251

4252+
async function handleServerFormSubmit(e) {
4253+
e.preventDefault();
4254+
4255+
const form = e.target;
4256+
const formData = new FormData(form);
4257+
const status = safeGetElement("serverFormError");
4258+
const loading = safeGetElement("add-server-loading"); // Add a loading spinner if needed
4259+
4260+
try {
4261+
const name = formData.get("name");
4262+
4263+
// Basic validation
4264+
const nameValidation = validateInputName(name, "server");
4265+
if (!nameValidation.valid) {
4266+
throw new Error(nameValidation.error);
4267+
}
4268+
4269+
if (loading) {
4270+
loading.style.display = "block";
4271+
}
4272+
4273+
if (status) {
4274+
status.textContent = "";
4275+
status.classList.remove("error-status");
4276+
}
4277+
4278+
const isInactiveCheckedBool = isInactiveChecked("servers");
4279+
formData.append("is_inactive_checked", isInactiveCheckedBool);
4280+
4281+
const response = await fetchWithTimeout(
4282+
`${window.ROOT_PATH}/admin/servers`,
4283+
{
4284+
method: "POST",
4285+
body: formData,
4286+
redirect: "manual",
4287+
},
4288+
);
4289+
4290+
const result = await response.json();
4291+
if (!result.success) {
4292+
console.log(result.message);
4293+
throw new Error(result.message || "Failed to add server.");
4294+
} else {
4295+
// Success redirect
4296+
const redirectUrl = isInactiveCheckedBool
4297+
? `${window.ROOT_PATH}/admin?include_inactive=true#catalog`
4298+
: `${window.ROOT_PATH}/admin#catalog`;
4299+
window.location.href = redirectUrl;
4300+
}
4301+
} catch (error) {
4302+
console.error("Add Server Error:", error);
4303+
if (status) {
4304+
status.textContent = error.message || "An error occurred.";
4305+
status.classList.add("error-status");
4306+
}
4307+
showErrorMessage(error.message); // Optional if you use global popup/snackbar
4308+
} finally {
4309+
if (loading) {
4310+
loading.style.display = "none";
4311+
}
4312+
}
4313+
}
4314+
42524315
async function handleToolFormSubmit(event) {
42534316
event.preventDefault();
42544317

@@ -4843,6 +4906,11 @@ function setupFormHandlers() {
48434906
paramButton.addEventListener("click", handleAddParameter);
48444907
}
48454908

4909+
const serverForm = safeGetElement("add-server-form");
4910+
if (serverForm) {
4911+
serverForm.addEventListener("submit", handleServerFormSubmit);
4912+
}
4913+
48464914
const editResourceForm = safeGetElement("edit-resource-form");
48474915
if (editResourceForm) {
48484916
editResourceForm.addEventListener("submit", () => {

mcpgateway/templates/admin.html

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,7 @@ <h2 class="text-2xl font-bold dark:text-gray-200">MCP Servers Catalog</h2>
240240
</div>
241241
<div class="bg-white shadow rounded-lg p-6 dark:bg-gray-800">
242242
<h3 class="text-lg font-bold mb-4 dark:text-gray-200">Add New Server</h3>
243-
<form method="POST" action="{{ root_path }}/admin/servers" id="add-server-form"
244-
onsubmit="return handleToggleSubmit(event, 'servers')"
245-
onreset="document.getElementById('associatedTools').selectedIndex = -1;">
243+
<form method="POST" id="add-server-form" onreset="document.getElementById('associatedTools').selectedIndex = -1;">
246244
<div class="grid grid-cols-1 gap-6">
247245
<div>
248246
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400">Name</label>
@@ -263,15 +261,13 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">Add New Server</h3>
263261
<label for="associatedTools" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
264262
Associated Tools
265263
</label>
266-
<select name="associatedTools" {# same name as before (minus camelCase) #} id="associatedTools" multiple
267-
{# remove if you want single-select #}
264+
<select name="associatedTools" id="associatedTools" multiple
268265
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300">
269266
{% for tool in tools %}
270-
{# value can be tool.id, tool.slug, or any key you use later #}
271267
<option value="{{ tool.id }}">{{ tool.name }}</option>
272268
{% endfor %}
273269
</select>
274-
<span id="selectedToolsPills" class="inline-flex flex-wrap gap-1 mt-2"></span> <!-- container -->
270+
<span id="selectedToolsPills" class="inline-flex flex-wrap gap-1 mt-2"></span>
275271
<span id="selectedToolsWarning" class="block text-sm font-semibold text-red-600 mt-1"></span>
276272
</div>
277273
<div>
@@ -289,8 +285,14 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">Add New Server</h3>
289285
placeholder="e.g., prompt1, prompt2" />
290286
</div>
291287
</div>
288+
<!-- 🔻 Error Display Span -->
289+
<span id="serverFormError" class="block text-sm font-semibold text-red-600 mt-2"></span>
290+
291+
<!-- 🔄 Optional Loading Indicator -->
292+
<div id="add-server-loading" class="hidden text-sm text-gray-500 mt-2">Submitting...</div>
293+
292294
<div class="mt-6">
293-
<button type="submit" data-testid="add-server-btn"
295+
<button type="submit"
294296
x-tooltip="'💡Creates a new Virtual Server by combining Tools, Resources & Prompts from global catalogs.'"
295297
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
296298
Add Server

tests/e2e/test_admin_apis.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ async def test_admin_server_lifecycle(self, client: AsyncClient, mock_settings):
161161

162162
# POST to /admin/servers should redirect
163163
response = await client.post("/admin/servers", data=form_data, headers=TEST_AUTH_HEADER, follow_redirects=False)
164-
assert response.status_code == 303
165-
assert "/admin#catalog" in response.headers["location"]
164+
assert response.status_code == 200
165+
# assert "/admin#catalog" in response.headers["location"]
166166

167167
# Get all servers and find our server
168168
response = await client.get("/admin/servers", headers=TEST_AUTH_HEADER)

tests/security/test_input_validation.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -480,16 +480,16 @@ def must_fail(schema: dict, label: str = "Invalid schema") -> None:
480480
try:
481481
tool = ToolCreate(name=self.VALID_TOOL_NAME, url=self.VALID_URL, input_schema=schema)
482482
assert tool.input_schema is not None
483-
print(f"✅ Valid schema #{i+1} accepted")
483+
print(f"✅ Valid schema #{i + 1} accepted")
484484
except ValidationError as err:
485-
print(f"❌ Valid schema #{i+1} rejected: {str(schema)[:50]}... -> {err}")
485+
print(f"❌ Valid schema #{i + 1} rejected: {str(schema)[:50]}... -> {err}")
486486
raise
487487

488488
# Invalid schemas - too deep
489489
for i, payload in enumerate(self.DEEP_NESTING_PAYLOADS):
490490
if isinstance(payload, dict):
491491
logger.debug(f"Testing deep nested schema")
492-
must_fail(payload, f"Deep nested schema #{i+1}")
492+
must_fail(payload, f"Deep nested schema #{i + 1}")
493493

494494
def test_tool_create_request_type_validation(self):
495495
"""Test request type validation based on integration type."""
@@ -638,7 +638,7 @@ def must_fail(content, label: str = "Invalid content") -> None:
638638
# Invalid content - HTML tags
639639
for i, payload in enumerate(self.XSS_PAYLOADS[:5]):
640640
logger.debug(f"Testing XSS payload in content: {payload[:50]}...")
641-
must_fail(payload, f"XSS content #{i+1}")
641+
must_fail(payload, f"XSS content #{i + 1}")
642642

643643
def test_resource_create_mime_type_validation(self):
644644
"""Test MIME type validation."""
@@ -911,7 +911,7 @@ def must_fail(payload: str, label: str = "Polyglot payload") -> None:
911911

912912
for i, payload in enumerate(polyglot_payloads):
913913
logger.debug(f"Testing polyglot payload: {payload[:50]}...")
914-
must_fail(payload, f"Polyglot #{i+1}")
914+
must_fail(payload, f"Polyglot #{i + 1}")
915915

916916
def test_timing_attack_prevention(self):
917917
"""Test that validation doesn't reveal timing information."""
@@ -1117,7 +1117,7 @@ def must_fail(template: str, label: str = "SSTI payload") -> None:
11171117

11181118
for i, payload in enumerate(ssti_payloads):
11191119
logger.debug(f"Testing SSTI payload: {payload[:50]}...")
1120-
must_fail(payload, f"SSTI #{i+1} ({payload[:20]}...)")
1120+
must_fail(payload, f"SSTI #{i + 1} ({payload[:20]}...)")
11211121

11221122
def test_regex_dos_prevention(self):
11231123
"""Test prevention of ReDoS attacks."""
@@ -1180,7 +1180,7 @@ def test_prototype_pollution_prevention(self):
11801180
]
11811181

11821182
for i, payload in enumerate(pollution_payloads):
1183-
logger.debug(f"Testing prototype pollution payload {i+1}")
1183+
logger.debug(f"Testing prototype pollution payload {i + 1}")
11841184
# Should handle these safely
11851185
try:
11861186
tool = ToolCreate(name=self.VALID_TOOL_NAME, url=self.VALID_URL, headers=payload)

tests/unit/mcpgateway/test_admin.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,9 @@ async def test_admin_add_server_with_empty_associations(self, mock_register_serv
287287
result = await admin_add_server(mock_request, mock_db, "test-user")
288288

289289
# Should still succeed
290-
assert isinstance(result, RedirectResponse)
291-
assert result.status_code == 303
290+
# assert isinstance(result, RedirectResponse)
291+
# changing the redirect status code (303) to success-status code (200)
292+
assert result.status_code == 200
292293

293294
@patch.object(ServerService, "update_server")
294295
async def test_admin_edit_server_with_root_path(self, mock_update_server, mock_request, mock_db):
@@ -1340,9 +1341,14 @@ async def test_exception_handling_consistency(self, exception_type, expected_sta
13401341

13411342
result = await admin_add_server(mock_request, mock_db, "test-user")
13421343

1344+
print(f"\nException: {exception_type.__name__ if hasattr(exception_type, '__name__') else exception_type}")
1345+
print(f"Result Type: {type(result)}")
1346+
print(f"Status Code: {getattr(result, 'status_code', 'N/A')}")
1347+
13431348
if expected_status in [422, 409]:
13441349
assert isinstance(result, JSONResponse)
13451350
assert result.status_code == expected_status
13461351
else:
13471352
# Generic exceptions return redirect
1348-
assert isinstance(result, RedirectResponse)
1353+
# assert isinstance(result, RedirectResponse)
1354+
assert isinstance(result, JSONResponse)

0 commit comments

Comments
 (0)