Skip to content

Commit efb5cf8

Browse files
Feat/test mcp server (#330)
* Added schemas & test endpoint for test server feature Signed-off-by: Reeve Barreto <[email protected]> * Added Gateway test button & modal Signed-off-by: Reeve Barreto <[email protected]> * Added Submit Test Form functionality Signed-off-by: Reeve Barreto <[email protected]> * fixed string conversion bug Signed-off-by: Reeve Barreto <[email protected]> * added response field in UI Signed-off-by: Reeve Barreto <[email protected]> * Fixed Codemirror instance duplication bug & Reset-Close form button Signed-off-by: Reeve Barreto <[email protected]> * Updated code documentation & comments Signed-off-by: Reeve Barreto <[email protected]> * make lint Signed-off-by: Reeve Barreto <[email protected]> * Fixed close modal to reset codemirror instances Signed-off-by: Reeve Barreto <[email protected]> * Fix flake8 Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: Reeve Barreto <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Co-authored-by: Mihai Criveti <[email protected]>
1 parent dde95fe commit efb5cf8

File tree

4 files changed

+235
-7
lines changed

4 files changed

+235
-7
lines changed

mcpgateway/admin.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
# Standard
2121
import json
2222
import logging
23+
import time
2324
from typing import Any, Dict, List, Union
2425

2526
# Third-Party
2627
from fastapi import APIRouter, Depends, HTTPException, Request
2728
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
29+
import httpx
2830
from sqlalchemy.orm import Session
2931

3032
# First-Party
@@ -33,6 +35,8 @@
3335
from mcpgateway.schemas import (
3436
GatewayCreate,
3537
GatewayRead,
38+
GatewayTestRequest,
39+
GatewayTestResponse,
3640
GatewayUpdate,
3741
PromptCreate,
3842
PromptMetrics,
@@ -1348,3 +1352,38 @@ async def admin_reset_metrics(db: Session = Depends(get_db), user: str = Depends
13481352
await server_service.reset_metrics(db)
13491353
await prompt_service.reset_metrics(db)
13501354
return {"message": "All metrics reset successfully", "success": True}
1355+
1356+
1357+
@admin_router.post("/gateways/test", response_model=GatewayTestResponse)
1358+
async def admin_test_gateway(request: GatewayTestRequest, user: str = Depends(require_auth)) -> GatewayTestResponse:
1359+
"""
1360+
Test a gateway by sending a request to its URL.
1361+
This endpoint allows administrators to test the connectivity and response
1362+
1363+
Args:
1364+
request (GatewayTestRequest): The request object containing the gateway URL and request details.
1365+
user (str): Authenticated user dependency.
1366+
1367+
Returns:
1368+
GatewayTestResponse: The response from the gateway, including status code, latency, and body
1369+
1370+
Raises:
1371+
HTTPException: If the gateway request fails (e.g., connection error, timeout).
1372+
"""
1373+
full_url = str(request.base_url).rstrip("/") + "/" + request.path.lstrip("/")
1374+
logger.debug(f"User {user} testing server at {request.base_url}.")
1375+
try:
1376+
async with httpx.AsyncClient(timeout=settings.federation_timeout, verify=not settings.skip_ssl_verify) as client:
1377+
start_time = time.monotonic()
1378+
response = await client.request(method=request.method.upper(), url=full_url, headers=request.headers, json=request.body)
1379+
latency_ms = int((time.monotonic() - start_time) * 1000)
1380+
try:
1381+
response_body: Union[dict, str] = response.json()
1382+
except json.JSONDecodeError:
1383+
response_body = response.text
1384+
1385+
return GatewayTestResponse(status_code=response.status_code, latency_ms=latency_ms, body=response_body)
1386+
1387+
except httpx.RequestError as e:
1388+
logger.warning(f"Gateway test failed: {e}")
1389+
raise HTTPException(status_code=502, detail=f"Request failed: {str(e)}")

mcpgateway/schemas.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,3 +1242,30 @@ def populate_associated_ids(cls, values):
12421242
if "associated_prompts" in values and values["associated_prompts"]:
12431243
values["associated_prompts"] = [prompt.id if hasattr(prompt, "id") else prompt for prompt in values["associated_prompts"]]
12441244
return values
1245+
1246+
1247+
class GatewayTestRequest(BaseModelWithConfigDict):
1248+
"""Schema for testing gateway connectivity.
1249+
1250+
Includes the HTTP method, base URL, path, optional headers, and body.
1251+
"""
1252+
1253+
method: str = Field(..., description="HTTP method to test (GET, POST, etc.)")
1254+
base_url: AnyHttpUrl = Field(..., description="Base URL of the gateway to test")
1255+
path: str = Field(..., description="Path to append to the base URL")
1256+
headers: Optional[Dict[str, str]] = Field(None, description="Optional headers for the request")
1257+
body: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Optional body for the request, can be a string or JSON object")
1258+
1259+
1260+
class GatewayTestResponse(BaseModelWithConfigDict):
1261+
"""Schema for the response from a gateway test request.
1262+
1263+
Contains:
1264+
- HTTP status code
1265+
- Latency in milliseconds
1266+
- Optional response body, which can be a string or JSON object
1267+
"""
1268+
1269+
status_code: int = Field(..., description="HTTP status code returned by the gateway")
1270+
latency_ms: int = Field(..., description="Latency of the request in milliseconds")
1271+
body: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Response body, can be a string or JSON object")

mcpgateway/static/admin.js

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ document.addEventListener("DOMContentLoaded", function () {
162162
status.textContent = "";
163163
status.classList.remove("error-status");
164164

165-
const is_inactive_checked = isInactiveChecked('gateways');
165+
const is_inactive_checked = isInactiveChecked('gateways');
166166
formData.append("is_inactive_checked", is_inactive_checked);
167167

168168
try {
@@ -301,8 +301,8 @@ document.addEventListener("DOMContentLoaded", function () {
301301
}
302302

303303
let formData = new FormData(this);
304-
const is_inactive_checked = isInactiveChecked('tools');
305-
formData.append("is_inactive_checked", is_inactive_checked);
304+
const is_inactive_checked = isInactiveChecked('tools');
305+
formData.append("is_inactive_checked", is_inactive_checked);
306306
try {
307307
let response = await fetch(`${window.ROOT_PATH}/admin/tools`, {
308308
method: "POST",
@@ -560,7 +560,7 @@ function handleToggleSubmit(event, type) {
560560
event.preventDefault();
561561

562562
// Get the value of 'is_inactive_checked' from the function
563-
const is_inactive_checked = isInactiveChecked(type);
563+
const is_inactive_checked = isInactiveChecked(type);
564564

565565
// Dynamically add the 'is_inactive_checked' value to the form
566566
const form = event.target;
@@ -1146,6 +1146,98 @@ async function viewGateway(gatewayId) {
11461146
}
11471147
}
11481148

1149+
// Function to test a gateway by sending a request to it
1150+
// This function opens a modal where the user can input the request details
1151+
// and see the response from the gateway.
1152+
let headersEditor, bodyEditor;
1153+
async function testGateway(gatewayURL) {
1154+
openModal("gateway-test-modal");
1155+
1156+
if (!headersEditor) {
1157+
headersEditor = CodeMirror.fromTextArea(document.getElementById('headers-json'), {
1158+
mode: "application/json",
1159+
lineNumbers: true,
1160+
});
1161+
headersEditor.setSize(null, 100);
1162+
}
1163+
1164+
if (!bodyEditor) {
1165+
bodyEditor = CodeMirror.fromTextArea(document.getElementById('body-json'), {
1166+
mode: "application/json",
1167+
lineNumbers: true
1168+
});
1169+
bodyEditor.setSize(null, 100);
1170+
}
1171+
1172+
document.getElementById("gateway-test-form").action = `${window.ROOT_PATH}/admin/gateways/test`;
1173+
document.getElementById("gateway-test-url").value = gatewayURL;
1174+
1175+
// Handle submission of the gateway test form
1176+
document.getElementById("gateway-test-form").addEventListener("submit", async function (e) {
1177+
e.preventDefault(); // prevent full page reload
1178+
1179+
// Show loading
1180+
document.getElementById("loading").classList.remove("hidden");
1181+
1182+
const form = e.target;
1183+
const url = form.action;
1184+
1185+
// Get form.elements and CodeMirror content
1186+
const base_url = form.elements["gateway-test-url"].value;
1187+
const method = form.elements["method"].value;
1188+
const path = form.elements["path"].value;
1189+
const headersRaw = headersEditor.getValue();
1190+
const bodyRaw = bodyEditor.getValue();
1191+
1192+
let headersParsed, bodyParsed;
1193+
try {
1194+
headersParsed = headersRaw ? JSON.parse(headersRaw) : undefined;
1195+
bodyParsed = bodyRaw ? JSON.parse(bodyRaw) : undefined;
1196+
} catch (err) {
1197+
document.getElementById("loading").classList.add("hidden");
1198+
document.getElementById("response-json").textContent = `❌ Invalid JSON: ${err.message}`;
1199+
document.getElementById("test-result").classList.remove("hidden");
1200+
return;
1201+
}
1202+
1203+
const payload = {
1204+
base_url,
1205+
method,
1206+
path,
1207+
headers: headersParsed,
1208+
body: bodyParsed,
1209+
};
1210+
1211+
try {
1212+
const response = await fetch(url, {
1213+
method: "POST",
1214+
headers: { "Content-Type": "application/json" },
1215+
body: JSON.stringify(payload),
1216+
});
1217+
1218+
const result = await response.json();
1219+
document.getElementById("response-json").textContent = JSON.stringify(result, null, 2);
1220+
} catch (err) {
1221+
document.getElementById("response-json").textContent = `❌ Error: ${err.message}`;
1222+
} finally {
1223+
document.getElementById("loading").classList.add("hidden");
1224+
document.getElementById("test-result").classList.remove("hidden");
1225+
}
1226+
});
1227+
1228+
// Close the modal and reset the form when the close button is clicked
1229+
document.getElementById("gateway-test-close").addEventListener("click", function () {
1230+
// Reset the form and CodeMirror editors
1231+
document.getElementById("gateway-test-form").reset();
1232+
headersEditor.setValue('');
1233+
bodyEditor.setValue('');
1234+
document.getElementById("response-json").textContent = '';
1235+
document.getElementById("test-result").classList.add("hidden");
1236+
1237+
closeModal("gateway-test-modal");
1238+
})
1239+
}
1240+
11491241
async function editGateway(gatewayId) {
11501242
try {
11511243
const response = await fetch(`${window.ROOT_PATH}/admin/gateways/${gatewayId}`);

mcpgateway/templates/admin.html

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,9 @@ <h2 class="text-2xl font-bold dark:text-gray-200">MCP Servers Catalog</h2>
331331
</div>
332332
<div class="bg-white shadow rounded-lg p-6 dark:bg-gray-800">
333333
<h3 class="text-lg font-bold mb-4 dark:text-gray-200">Add New Server</h3>
334-
<form method="POST"
335-
action="{{ root_path }}/admin/servers"
336-
id="add-server-form"
334+
<form method="POST"
335+
action="{{ root_path }}/admin/servers"
336+
id="add-server-form"
337337
onsubmit="return handleToggleSubmit(event, 'servers')"
338338
onreset="document.getElementById('associatedTools').selectedIndex = -1;">
339339
<div class="grid grid-cols-1 gap-6">
@@ -1448,6 +1448,11 @@ <h2 class="text-2xl font-bold dark:text-gray-200">Federated Gateways (MCP Regist
14481448
gateway.lastSeen else 'Never' }}
14491449
</td>
14501450
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
1451+
<button
1452+
onclick="testGateway('{{ gateway.url }}')"
1453+
class="text-stone-600 hover:text-stone-900 mr-2">
1454+
Test
1455+
</button>
14511456
{% if gateway.enabled %}
14521457
<form
14531458
method="POST"
@@ -2396,6 +2401,71 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Gateway Details
23962401
</div>
23972402
</div>
23982403

2404+
<!-- Test Gateway Modal -->
2405+
<div
2406+
id="gateway-test-modal"
2407+
class="fixed z-50 inset-0 bg-gray-800 bg-opacity-75 flex items-center justify-center hidden">
2408+
<div class="bg-white dark:bg-gray-900 w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg shadow-lg p-6">
2409+
<h2 class="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">Test Server Connectivity</h2>
2410+
2411+
<form id="gateway-test-form" class="space-y-4">
2412+
<div>
2413+
<label class="block text-sm font-medium text-gray-700">Server URL</label>
2414+
<input name="url" type="text"
2415+
id="gateway-test-url"
2416+
class="mt-1 block w-full rounded-md shadow-sm p-1" />
2417+
</div>
2418+
<div>
2419+
<label class="block text-sm font-medium text-gray-700">Method</label>
2420+
<select
2421+
name="method" id="gateway-test-method"
2422+
class="mt-1 block w-full rounded-md shadow-sm p-1"
2423+
>
2424+
<option>GET</option>
2425+
<option>POST</option>
2426+
<option>PUT</option>
2427+
<option>DELETE</option>
2428+
<option>PATCH</option>
2429+
</select>
2430+
</div>
2431+
<div>
2432+
<label class="block text-sm font-medium text-gray-700">Path</label>
2433+
<input id="gateway-test-path" name="path" type="text" placeholder="/health"
2434+
class="mt-1 block w-full rounded-md shadow-sm p-1" />
2435+
</div>
2436+
<div>
2437+
<label class="block text-sm font-medium text-gray-700">Headers (JSON)</label>
2438+
<textarea id="headers-json" class="mt-1 block w-full rounded-md shadow-sm p-1"></textarea>
2439+
</div>
2440+
2441+
<div>
2442+
<label class="block text-sm font-medium text-gray-700">Body (JSON)</label>
2443+
<textarea id="body-json" class="mt-1 block w-full rounded-md shadow-sm p-1"></textarea>
2444+
</div>
2445+
2446+
<div class="flex">
2447+
<button type="submit" id="gateway-test-submit"
2448+
class="w-full text-center px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700">
2449+
Send
2450+
</button>
2451+
<p class="p-1"></p>
2452+
<button id="gateway-test-close" type="button" class="w-full text-center px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50">
2453+
Close
2454+
</button>
2455+
</div>
2456+
2457+
<div id="test-result" class="hidden">
2458+
<label class="block text-sm font-medium text-gray-700">Response</label>
2459+
<pre id="response-json" class="bg-gray-100 p-2 rounded overflow-auto text-sm text-gray-800 max-h-64"></pre>
2460+
</div>
2461+
</form>
2462+
2463+
<div id="loading" class="mt-4 hidden">
2464+
<div class="spinner border-t-4 border-blue-600 w-6 h-6 rounded-full animate-spin"></div>
2465+
</div>
2466+
</div>
2467+
</div>
2468+
23992469
<!-- Gateway Edit Modal -->
24002470
<div
24012471
id="gateway-edit-modal"

0 commit comments

Comments
 (0)