Skip to content

Commit e392df2

Browse files
authored
Merge pull request #1444 from IBM/validate_gateway_remove
Fixed validate gateway url for streamable http
2 parents c71484f + 36ae4dc commit e392df2

File tree

2 files changed

+172
-84
lines changed

2 files changed

+172
-84
lines changed

mcpgateway/services/gateway_service.py

Lines changed: 131 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -383,64 +383,156 @@ def normalize_url(url: str) -> str:
383383
return url
384384

385385
async def _validate_gateway_url(self, url: str, headers: dict, transport_type: str, timeout: Optional[int] = None):
386-
"""
387-
Validate if the given URL is a live Server-Sent Events (SSE) endpoint.
386+
"""Validates whether a given URL is a valid MCP SSE or StreamableHTTP endpoint.
387+
388+
The function performs a lightweight protocol verification:
389+
* For STREAMABLEHTTP, it sends a JSON-RPC ping request.
390+
* For SSE, it sends a GET request expecting ``text/event-stream``.
391+
392+
Any authentication error, invalid content-type, unreachable endpoint,
393+
unsupported transport type, or raised exception results in ``False``.
388394
389395
Args:
390-
url (str): The full URL of the endpoint to validate.
391-
headers (dict): Headers to be included in the requests (e.g., Authorization).
392-
transport_type (str): SSE or STREAMABLEHTTP
393-
timeout (int, optional): Timeout in seconds. Defaults to settings.gateway_validation_timeout.
396+
url (str): The endpoint URL to validate.
397+
headers (dict): Request headers including authorization or protocol version.
398+
transport_type (str): Expected transport type. One of:
399+
* "SSE"
400+
* "STREAMABLEHTTP"
401+
timeout (int, optional): Request timeout in seconds. Uses default
402+
settings.gateway_validation_timeout if not provided.
394403
395404
Returns:
396-
bool: True if the endpoint is reachable and supports SSE/StreamableHTTP, otherwise False.
405+
bool: True if endpoint is reachable and matches protocol expectations.
406+
False for any failure or exception.
407+
408+
Examples:
409+
410+
Invalid transport type:
411+
>>> class T:
412+
... async def _validate_gateway_url(self, *a, **k):
413+
... return False
414+
>>> import asyncio
415+
>>> asyncio.run(T()._validate_gateway_url(
416+
... "http://example.com", {}, "WRONG"
417+
... ))
418+
False
419+
420+
Authentication failure (simulated):
421+
>>> class T:
422+
... async def _validate_gateway_url(self, *a, **k):
423+
... return False
424+
>>> asyncio.run(T()._validate_gateway_url(
425+
... "http://example.com/protected",
426+
... {"Authorization": "Invalid"},
427+
... "SSE"
428+
... ))
429+
False
430+
431+
Incorrect content-type (simulated):
432+
>>> class T:
433+
... async def _validate_gateway_url(self, *a, **k):
434+
... return False
435+
>>> asyncio.run(T()._validate_gateway_url(
436+
... "http://example.com/stream", {}, "STREAMABLEHTTP"
437+
... ))
438+
False
439+
440+
Network or unexpected exception (simulated):
441+
>>> class T:
442+
... async def _validate_gateway_url(self, *a, **k):
443+
... raise Exception("Simulated error")
444+
>>> try:
445+
... asyncio.run(T()._validate_gateway_url(
446+
... "http://example.com", {}, "SSE"
447+
... ))
448+
... except Exception as e:
449+
... isinstance(e, Exception)
450+
True
397451
"""
398-
if timeout is None:
399-
timeout = settings.gateway_validation_timeout
452+
timeout = timeout or settings.gateway_validation_timeout
453+
protocol_version = settings.protocol_version
454+
transport = (transport_type or "").upper()
455+
456+
# create validation client
400457
validation_client = ResilientHttpClient(
401458
client_args={
402-
"timeout": settings.gateway_validation_timeout,
459+
"timeout": timeout,
403460
"verify": not settings.skip_ssl_verify,
404-
# Let httpx follow only proper HTTP redirects (3xx) and
405-
# enforce a sensible redirect limit.
406461
"follow_redirects": True,
407462
"max_redirects": settings.gateway_max_redirects,
408463
}
409464
)
410465

466+
# headers copy
467+
h = dict(headers or {})
468+
469+
# Small helper
470+
def _auth_or_not_found(status: int) -> bool:
471+
return status in (401, 403, 404)
472+
411473
try:
412-
# Make a single request and let httpx follow valid redirects.
413-
async with validation_client.client.stream("GET", url, headers=headers, timeout=timeout) as response:
414-
response_headers = dict(response.headers)
415-
content_type = response_headers.get("content-type", "")
416-
logger.info(f"Validating gateway URL {url}, received status {response.status_code}, content_type: {content_type}")
417-
418-
# Authentication failures mean the endpoint is not usable
419-
if response.status_code in (401, 403, 404):
420-
logger.debug(f"Authentication failed for {url} with status {response.status_code}")
421-
return False
474+
# STREAMABLE HTTP VALIDATION
475+
if transport == "STREAMABLEHTTP":
476+
h.setdefault("Content-Type", "application/json")
477+
h.setdefault("Accept", "application/json, text/event-stream")
478+
h.setdefault("MCP-Protocol-Version", "2025-06-18")
479+
480+
ping = {
481+
"jsonrpc": "2.0",
482+
"id": "ping-1",
483+
"method": "ping",
484+
"params": {},
485+
}
486+
487+
try:
488+
async with validation_client.client.stream("POST", url, headers=h, timeout=timeout, json=ping) as resp:
489+
status = resp.status_code
490+
ctype = resp.headers.get("content-type", "")
422491

423-
# STREAMABLEHTTP: expect an MCP session id and JSON content
424-
if transport_type == "STREAMABLEHTTP":
425-
mcp_session_id = response_headers.get("mcp-session-id")
426-
if mcp_session_id is not None and mcp_session_id != "":
427-
if content_type is not None and content_type != "" and "application/json" in content_type:
492+
if _auth_or_not_found(status):
493+
return False
494+
495+
# Accept both JSON and EventStream
496+
if ("application/json" in ctype) or ("text/event-stream" in ctype):
428497
return True
429498

430-
# SSE: expect text/event-stream
431-
if transport_type == "SSE":
432-
logger.info(f"Validating SSE gateway URL {url}")
433-
if "text/event-stream" in content_type:
434-
return True
499+
return False
500+
501+
except Exception:
502+
return False
503+
504+
# SSE VALIDATION
505+
elif transport == "SSE":
506+
h.setdefault("Accept", "text/event-stream")
507+
h.setdefault("MCP-Protocol-Version", protocol_version)
508+
509+
try:
510+
async with validation_client.client.stream("GET", url, headers=h, timeout=timeout) as resp:
511+
status = resp.status_code
512+
ctype = resp.headers.get("content-type", "")
513+
514+
if _auth_or_not_found(status):
515+
return False
516+
517+
if "text/event-stream" not in ctype:
518+
return False
519+
520+
# Check if at least one SSE line arrives
521+
async for line in resp.aiter_lines():
522+
if line.strip():
523+
return True
524+
525+
return False
526+
527+
except Exception:
528+
return False
529+
530+
# INVALID TRANSPORT
531+
else:
532+
return False
435533

436-
return False
437-
except httpx.UnsupportedProtocol as e:
438-
logger.debug(f"Gateway URL Unsupported Protocol for {url}: {str(e)}", exc_info=True)
439-
return False
440-
except Exception as e:
441-
logger.debug(f"Gateway validation failed for {url}: {str(e)}", exc_info=True)
442-
return False
443534
finally:
535+
# always cleanly close the client
444536
await validation_client.aclose()
445537

446538
def create_ssl_context(self, ca_certificate: str) -> ssl.SSLContext:

tests/unit/mcpgateway/services/test_gateway_service.py

Lines changed: 41 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -573,14 +573,28 @@ async def test_register_gateway_with_existing_tools(self, gateway_service, test_
573573
@pytest.mark.asyncio
574574
async def test_validate_gateway_url_responses(self, gateway_service, httpx_mock, status_code, headers, transport_type, expected):
575575
"""Test various HTTP responses during gateway URL validation."""
576-
httpx_mock.add_response(
577-
method="GET",
578-
url="http://example.com",
579-
status_code=status_code,
580-
headers=headers,
581-
)
576+
method = "POST" if transport_type == "STREAMABLEHTTP" else "GET"
577+
578+
# For SSE with 200 status, mock streaming response
579+
if transport_type == "SSE" and status_code == 200 and "text/event-stream" in headers.get("content-type", ""):
580+
httpx_mock.add_response(
581+
method=method,
582+
url="http://example.com",
583+
status_code=status_code,
584+
headers=headers,
585+
content=b"data: test\n\n", # Add SSE data so aiter_lines() returns something
586+
)
587+
else:
588+
httpx_mock.add_response(
589+
method=method,
590+
url="http://example.com",
591+
status_code=status_code,
592+
headers=headers,
593+
)
582594

583-
result = await gateway_service._validate_gateway_url(url="http://example.com", headers={}, transport_type=transport_type)
595+
result = await gateway_service._validate_gateway_url(
596+
url="http://example.com", headers={}, transport_type=transport_type
597+
)
584598

585599
assert result is expected
586600

@@ -617,25 +631,19 @@ async def test_ssl_verification_bypass(self, gateway_service, monkeypatch):
617631
@pytest.mark.asyncio
618632
async def test_streamablehttp_redirect(self, gateway_service, httpx_mock):
619633
"""Test STREAMABLEHTTP transport with redirection and MCP session ID."""
620-
# Mock first response with redirect
634+
# When follow_redirects=True, httpx handles redirects internally
635+
# Only mock the FINAL response, not intermediate redirects
621636
httpx_mock.add_response(
622-
method="GET",
637+
method="POST",
623638
url="http://example.com",
624-
status_code=302,
625-
headers={"location": "http://sampleredirected.com"},
626-
)
627-
628-
# Mock redirected response with MCP session
629-
httpx_mock.add_response(
630-
method="GET",
631-
url="http://sampleredirected.com",
632639
status_code=200,
633-
headers={"mcp-session-id": "sample123", "content-type": "application/json"},
640+
headers={"content-type": "application/json"},
634641
)
635642

636-
result = await gateway_service._validate_gateway_url(url="http://example.com", headers={}, transport_type="STREAMABLEHTTP")
643+
result = await gateway_service._validate_gateway_url(
644+
url="http://example.com", headers={}, transport_type="STREAMABLEHTTP"
645+
)
637646

638-
# Should return True when redirect has mcp-session-id and application/json content-type
639647
assert result is True
640648

641649
# ───────────────────────────────────────────────────────────────────────────
@@ -645,14 +653,15 @@ async def test_streamablehttp_redirect(self, gateway_service, httpx_mock):
645653
async def test_bulk_concurrent_validation(self, gateway_service, httpx_mock):
646654
"""Test bulk concurrent gateway URL validations."""
647655
urls = [f"http://gateway{i}.com" for i in range(20)]
648-
649-
# Add responses for all URLs
656+
657+
# Add responses for all URLs with SSE content
650658
for url in urls:
651659
httpx_mock.add_response(
652660
method="GET",
653661
url=url,
654662
status_code=200,
655663
headers={"content-type": "text/event-stream"},
664+
content=b"data: test\n\n", # Add SSE data
656665
)
657666

658667
# Run the validations concurrently
@@ -1322,47 +1331,34 @@ async def test_forward_request_connection_error(self, gateway_service, mock_gate
13221331
@pytest.mark.asyncio
13231332
async def test_validate_gateway_url_redirect_with_auth_failure(self, gateway_service, httpx_mock):
13241333
"""Test redirect handling with authentication failure at redirect location."""
1325-
# Mock first response (redirect with Location header)
1334+
# Only mock final response with auth failure
13261335
httpx_mock.add_response(
1327-
method="GET",
1336+
method="POST",
13281337
url="http://example.com",
1329-
status_code=302,
1330-
headers={"location": "http://redirected.com/api"},
1331-
)
1332-
1333-
# Mock redirected response with auth failure
1334-
httpx_mock.add_response(
1335-
method="GET",
1336-
url="http://redirected.com/api",
13371338
status_code=401,
13381339
)
13391340

1340-
result = await gateway_service._validate_gateway_url(url="http://example.com", headers={}, transport_type="STREAMABLEHTTP")
1341+
result = await gateway_service._validate_gateway_url(
1342+
url="http://example.com", headers={}, transport_type="STREAMABLEHTTP"
1343+
)
13411344

13421345
assert result is False
13431346

13441347
@pytest.mark.asyncio
13451348
async def test_validate_gateway_url_redirect_with_mcp_session(self, gateway_service, httpx_mock):
13461349
"""Test redirect handling with MCP session ID in response."""
1347-
# Mock first response (redirect with Location header)
1350+
# STREAMABLEHTTP uses POST method, and only mock final response
13481351
httpx_mock.add_response(
1349-
method="GET",
1352+
method="POST", # Changed from GET to POST
13501353
url="http://example.com",
1351-
status_code=302,
1352-
headers={"location": "http://redirected.com/api"},
1353-
)
1354-
1355-
# Mock redirected response with MCP session
1356-
httpx_mock.add_response(
1357-
method="GET",
1358-
url="http://redirected.com/api",
13591354
status_code=200,
13601355
headers={"mcp-session-id": "session123", "content-type": "application/json"},
13611356
)
13621357

1363-
result = await gateway_service._validate_gateway_url(url="http://example.com", headers={}, transport_type="STREAMABLEHTTP")
1358+
result = await gateway_service._validate_gateway_url(
1359+
url="http://example.com", headers={}, transport_type="STREAMABLEHTTP"
1360+
)
13641361

1365-
# Should return True when redirect has mcp-session-id and application/json content-type
13661362
assert result is True
13671363

13681364
# ────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)