Skip to content

Commit 702aaec

Browse files
author
Sébastien MORAND
committed
feat: resolve virtual servers by name in RFC 9728 well-known and MCP endpoints
The well-known OAuth Protected Resource endpoint (RFC 9728) only accepted UUID server identifiers, forcing clients to use opaque hex strings in URLs. This adds server name resolution as a fallback while keeping full UUID backward compatibility. Changes: - well_known.py: accept server name in addition to UUID, resolve to canonical UUID for the resource URL (RFC 9728 stability) - server_service.py: add resolve_server_id() that tries PK lookup first, then falls back to name lookup - streamablehttp_transport.py: use resolve_server_id() in _validate_server_id and _check_server_oauth_enforcement for consistent name support - Update tests to reflect name resolution behavior Security: server name input is validated against a strict alphanumeric pattern (SERVER_NAME_PATTERN) to prevent path traversal and injection. Signed-off-by: Sébastien MORAND <sebastien.morand@ibm.com>
1 parent 8866dd2 commit 702aaec

File tree

4 files changed

+60
-18
lines changed

4 files changed

+60
-18
lines changed

mcpgateway/routers/well_known.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# First-Party
2525
from mcpgateway.config import settings
2626
from mcpgateway.db import get_db
27+
from mcpgateway.db import Server as DbServer
2728
from mcpgateway.services.logging_service import LoggingService
2829
from mcpgateway.services.server_service import ServerError, ServerNotFoundError, ServerService
2930
from mcpgateway.utils.log_sanitizer import sanitize_for_log
@@ -38,6 +39,9 @@
3839
# UUID validation pattern for RFC 9728 endpoint
3940
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$", re.IGNORECASE)
4041

42+
# Server name pattern: alphanumeric, hyphens, underscores (prevents path traversal/injection)
43+
SERVER_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,253}[a-zA-Z0-9]$|^[a-zA-Z0-9]$")
44+
4145
# Well-known URI registry with validation
4246
WELL_KNOWN_REGISTRY = {
4347
"robots.txt": {"content_type": "text/plain", "description": "Robot exclusion standard", "rfc": "RFC 9309"},
@@ -169,21 +173,27 @@ async def get_oauth_protected_resource_rfc9728(
169173
logger.debug(f"Invalid RFC 9728 path format: {sanitize_for_log(path)}")
170174
raise HTTPException(status_code=404, detail="Invalid resource path format. Expected: /.well-known/oauth-protected-resource/servers/{server_id}/mcp")
171175

172-
server_id = path_parts[1]
173-
174-
# Validate server_id is a valid UUID (prevents path traversal and injection)
175-
if not UUID_PATTERN.match(server_id):
176-
# Sanitize untrusted server_id before logging to prevent log injection
177-
logger.warning(f"Invalid server_id format (not a UUID): {sanitize_for_log(server_id)}")
178-
raise HTTPException(status_code=404, detail="Invalid server_id format. Must be a valid UUID.")
176+
server_id_or_name = path_parts[1]
179177

180178
# Reject paths with extra segments after /mcp (e.g., servers/uuid/mcp/extra)
181179
if len(path_parts) > 3:
182-
# Sanitize untrusted path before logging to prevent log injection
183180
logger.warning(f"RFC 9728 path has unexpected segments: {sanitize_for_log(path)}")
184181
raise HTTPException(status_code=404, detail="Invalid resource path format. Expected: /.well-known/oauth-protected-resource/servers/{server_id}/mcp")
185182

186-
# Build resource URL with /mcp suffix per MCP specification
183+
# Resolve server_id: accept UUID or server name
184+
if UUID_PATTERN.match(server_id_or_name):
185+
server_id = server_id_or_name
186+
elif SERVER_NAME_PATTERN.match(server_id_or_name):
187+
# Resolve server name to UUID via DB lookup
188+
server = db.query(DbServer).filter(DbServer.name == server_id_or_name, DbServer.enabled.is_(True)).first()
189+
if not server:
190+
raise HTTPException(status_code=404, detail="Server not found")
191+
server_id = server.id
192+
else:
193+
logger.warning(f"Invalid server identifier format: {sanitize_for_log(server_id_or_name)}")
194+
raise HTTPException(status_code=404, detail="Invalid server identifier format. Must be a UUID or a valid server name.")
195+
196+
# Build resource URL with canonical UUID (ensures stable resource identifiers per RFC 9728)
187197
base_url = get_base_url_with_protocol(request)
188198
resource_url = f"{base_url}/servers/{server_id}/mcp"
189199

mcpgateway/services/server_service.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,28 @@ def __init__(self) -> None:
302302
self._audit_trail = get_audit_trail_service()
303303
self._performance_tracker = get_performance_tracker()
304304

305+
def resolve_server_id(self, db: Session, server_id_or_name: str) -> Optional[str]:
306+
"""Resolve a server identifier (UUID or name) to a canonical UUID.
307+
308+
Accepts either a hex UUID or a server name. Returns the server's primary
309+
key if found, or None if no matching server exists.
310+
311+
Args:
312+
db: Database session.
313+
server_id_or_name: UUID hex string or server name.
314+
315+
Returns:
316+
The server UUID string, or None if not found.
317+
"""
318+
# Try direct PK lookup first (fast path)
319+
server = db.get(DbServer, server_id_or_name)
320+
if server:
321+
return server.id
322+
323+
# Fallback: lookup by name
324+
server = db.query(DbServer).filter(DbServer.name == server_id_or_name).first()
325+
return server.id if server else None
326+
305327
async def initialize(self) -> None:
306328
"""Initialize the server service."""
307329
logger.info("Initializing server service")
@@ -1901,7 +1923,11 @@ def get_oauth_protected_resource_metadata(self, db: Session, server_id: str, res
19011923
>>> callable(service.get_oauth_protected_resource_metadata)
19021924
True
19031925
"""
1904-
server = db.get(DbServer, server_id)
1926+
# Resolve server by UUID or name
1927+
resolved_id = self.resolve_server_id(db, server_id)
1928+
if not resolved_id:
1929+
raise ServerNotFoundError(f"Server not found: {server_id}")
1930+
server = db.get(DbServer, resolved_id)
19051931

19061932
# Return not found for non-existent, disabled, or non-public servers
19071933
# (avoids leaking information about private/team servers)

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -951,12 +951,15 @@ async def _check_server_oauth_enforcement(server_id: str, user_context: Optional
951951

952952
try:
953953
async with get_db() as db:
954+
# Support both UUID and name resolution
954955
server = db.execute(select(DbServer).where(DbServer.id == server_id)).scalar_one_or_none()
956+
if not server:
957+
server = db.execute(select(DbServer).where(DbServer.name == server_id)).scalar_one_or_none()
955958
if server and server.oauth_enabled:
956959
logger.warning("OAuth required for server %s but caller is unauthenticated", server_id)
957960
raise OAuthRequiredError(
958961
"This server requires OAuth authentication. Please provide a valid access token.",
959-
server_id=server_id,
962+
server_id=server.id,
960963
)
961964
_oauth_checked_var.set(True)
962965
except SQLAlchemyError as exc:
@@ -2835,18 +2838,19 @@ async def _validate_server_id(match: "re.Match[str] | None", path: str, scope: S
28352838
server_id = match.group("server_id")
28362839
# SECURITY: Validate that the server_id exists in the database
28372840
# to prevent unauthorized access via invalid server IDs.
2838-
# Uses the shared BaseService.entity_exists() for a lightweight
2839-
# EXISTS check — no row data is loaded.
2841+
# Supports both UUID and server name resolution.
28402842
try:
28412843
# First-Party
28422844
from mcpgateway.services.server_service import server_service as _server_svc # pylint: disable=import-outside-toplevel,no-name-in-module
28432845

28442846
async with get_db() as db:
2845-
if not await _server_svc.entity_exists(db, server_id):
2847+
resolved_id = _server_svc.resolve_server_id(db, server_id)
2848+
if not resolved_id:
28462849
logger.warning("Invalid server ID in MCP request path: %s", server_id)
28472850
response = ORJSONResponse({"detail": "Server not found"}, status_code=404)
28482851
await response(scope, receive, send)
28492852
return _REJECT
2853+
server_id = resolved_id
28502854
except Exception as e:
28512855
logger.error("Failed to validate server ID %s: %s", server_id, e)
28522856
response = ORJSONResponse({"detail": "Service unavailable — unable to verify server"}, status_code=503)

tests/unit/mcpgateway/routers/test_well_known_rfc9728.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,21 +125,23 @@ def override_get_db():
125125
app.dependency_overrides.pop(get_db, None)
126126

127127
def test_rfc9728_endpoint_invalid_uuid(self, app):
128-
"""Test RFC 9728 endpoint rejects non-UUID server IDs."""
128+
"""Test RFC 9728 endpoint rejects invalid identifiers and resolves names."""
129129
mock_db = MagicMock()
130+
# Name lookups return no results (server not found)
131+
mock_db.query.return_value.filter.return_value.first.return_value = None
130132

131133
def override_get_db():
132134
yield mock_db
133135

134136
app.dependency_overrides[get_db] = override_get_db
135137
client = TestClient(app)
136138

137-
# Not a valid UUID
139+
# Valid name format but server does not exist => 404 Server not found
138140
response = client.get("/.well-known/oauth-protected-resource/servers/not-a-uuid/mcp")
139141
assert response.status_code == 404
140-
assert "Invalid server_id format" in response.json()["detail"]
142+
assert "Server not found" in response.json()["detail"]
141143

142-
# Path traversal attempt
144+
# Path traversal attempt (invalid name format)
143145
response = client.get("/.well-known/oauth-protected-resource/servers/../admin/mcp")
144146
assert response.status_code == 404
145147

0 commit comments

Comments
 (0)