diff --git a/.env.example b/.env.example index 75ed0d2837..5a5a676b03 100644 --- a/.env.example +++ b/.env.example @@ -347,6 +347,8 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # Send db config to redis to be consumed by experimental Dataplane DATAPLANE_PUBLISHER = false +DATAPLANE_PUBLISHER_INTERVAL_SECONDS = 60 + # ============================================================================= diff --git a/.secrets.baseline b/.secrets.baseline index 7af308c352..76bc809c62 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-07-08T12:40:34Z", + "generated_at": "2026-07-09T15:51:34Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -116,7 +116,7 @@ "hashed_secret": "08cd923367890009657eab812753379bdb321eeb", "is_secret": false, "is_verified": false, - "line_number": 708, + "line_number": 710, "type": "Basic Auth Credentials", "verified_result": null }, @@ -124,7 +124,7 @@ "hashed_secret": "14f8aa3e560a47851908ab0f04ec856dbc512d93", "is_secret": false, "is_verified": false, - "line_number": 936, + "line_number": 938, "type": "Secret Keyword", "verified_result": null }, @@ -132,7 +132,7 @@ "hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3", "is_secret": false, "is_verified": false, - "line_number": 1234, + "line_number": 1236, "type": "Secret Keyword", "verified_result": null }, @@ -140,7 +140,7 @@ "hashed_secret": "7b4455a56fbf1d198e45e04c437488514645a82c", "is_secret": false, "is_verified": false, - "line_number": 1260, + "line_number": 1262, "type": "Secret Keyword", "verified_result": null }, @@ -148,7 +148,7 @@ "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", "is_secret": false, "is_verified": false, - "line_number": 1268, + "line_number": 1270, "type": "Secret Keyword", "verified_result": null }, @@ -156,7 +156,7 @@ "hashed_secret": "ac371b6dcce28a86c90d12bc57d946a800eebf17", "is_secret": false, "is_verified": false, - "line_number": 1347, + "line_number": 1349, "type": "Secret Keyword", "verified_result": null }, @@ -164,7 +164,7 @@ "hashed_secret": "0b6ec68df700dec4dcd64babd0eda1edccddace1", "is_secret": false, "is_verified": false, - "line_number": 1352, + "line_number": 1354, "type": "Secret Keyword", "verified_result": null }, @@ -172,7 +172,7 @@ "hashed_secret": "4ad6f0082ee224001beb3ca5c3e81c8ceea5ed86", "is_secret": false, "is_verified": false, - "line_number": 1357, + "line_number": 1359, "type": "Secret Keyword", "verified_result": null }, @@ -180,7 +180,7 @@ "hashed_secret": "cb32747fcfb55eaa194c8cd8e4ba7d49ada08a94", "is_secret": false, "is_verified": false, - "line_number": 1363, + "line_number": 1365, "type": "Secret Keyword", "verified_result": null }, @@ -188,7 +188,7 @@ "hashed_secret": "6c178d51b13520496dbc767ed3d9d7aa5803ac72", "is_secret": false, "is_verified": false, - "line_number": 1375, + "line_number": 1377, "type": "Secret Keyword", "verified_result": null }, @@ -196,7 +196,7 @@ "hashed_secret": "ca45060a53fd8a255d1a83ee8d2f025283ccc66e", "is_secret": false, "is_verified": false, - "line_number": 1393, + "line_number": 1395, "type": "Secret Keyword", "verified_result": null }, @@ -204,7 +204,7 @@ "hashed_secret": "910fbf00f58e9bcb095ea26a75cc1d9a3355e671", "is_secret": false, "is_verified": false, - "line_number": 1454, + "line_number": 1456, "type": "Secret Keyword", "verified_result": null } diff --git a/mcpgateway/config.py b/mcpgateway/config.py index f5c1a16fb6..0198270d66 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -2783,6 +2783,12 @@ def validate_ratelimiter_redis_url(cls, v: Optional[str]) -> Optional[str]: dataplane_publisher: bool = Field(default=False, description="Send data from CF to Rust experimental dataplane") + dataplane_publisher_interval_seconds: int = Field( + default=60, + ge=1, + description="Seconds between dataplane publisher snapshots to Redis; UserConfig keys expire 10 seconds after the next expected snapshot", + ) + # Well-Known URI Configuration # =================================== diff --git a/mcpgateway/services/dataplane_publisher.py b/mcpgateway/services/dataplane_publisher.py index 927c67825f..14a0f3d48c 100644 --- a/mcpgateway/services/dataplane_publisher.py +++ b/mcpgateway/services/dataplane_publisher.py @@ -22,6 +22,7 @@ from sqlalchemy import select # First-Party +from mcpgateway.config import settings from mcpgateway.db import ( EmailTeamMember, EmailUser, @@ -42,12 +43,14 @@ logger = logging.getLogger(__name__) USER_CONFIG_KEY = "UserConfig" -REDIS_PUBLISHER_TIME = 60 # Publish interval in seconds -# Keys are not deleted explicitly; stale configs expire via Redis TTL. -PUBLISHER_TTL = 70 +REDIS_PUBLISHER_TIME = settings.dataplane_publisher_interval_seconds +# Keys are not deleted explicitly; stale configs expire via Redis TTL. The +# TTL must outlive a full missed publish cycle: under load the loop's timer +# can fire late, and with only a few seconds of margin every UserConfig key +# expires at once, taking dataplane config down for all subjects until the +# next snapshot. +PUBLISHER_TTL = REDIS_PUBLISHER_TIME * 2 + 10 -# Worker ID for multi-worker coordination (same pattern as session_affinity.py) -WORKER_ID = f"{socket.gethostname()}:{os.getpid()}" PUBLISHER_LOCK_KEY = "mcpgw:dataplane_publisher:lock" @@ -86,6 +89,11 @@ def __init__(self) -> None: """Initialize the publisher state and dependent services.""" self.task: asyncio.Task[None] | None = None self._shutdown_event = asyncio.Event() + # Computed per instance so each gunicorn worker gets its own id; + # a module-level constant is evaluated in the master before fork, + # which gives every worker the same id and defeats the lock's + # compare-and-swap ownership check (same pattern as session_affinity.py). + self.worker_id = f"{socket.gethostname()}:{os.getpid()}" async def start(self) -> None: """Start the background publisher task.""" @@ -137,7 +145,7 @@ async def publish_to_redis(self) -> None: lock_ttl = REDIS_PUBLISHER_TIME + 30 # Lock expires 30s after publish interval acquired = await redis.set( PUBLISHER_LOCK_KEY, - WORKER_ID, + self.worker_id, nx=True, # Only set if not exists ex=lock_ttl, # Auto-expire if worker crashes ) @@ -149,7 +157,7 @@ async def publish_to_redis(self) -> None: continue # We hold the lock - publish data - logger.info("Worker %s publishing dataplane payload...", WORKER_ID) + logger.info("Worker %s publishing dataplane payload...", self.worker_id) payload = await self.fetch_payload() if payload is None: @@ -181,7 +189,7 @@ async def publish_to_redis(self) -> None: return 0 """ try: - await redis.eval(release_script, 1, PUBLISHER_LOCK_KEY, WORKER_ID) + await redis.eval(release_script, 1, PUBLISHER_LOCK_KEY, self.worker_id) except Exception as e: logger.warning("Failed to release lock: %s", e) @@ -211,15 +219,25 @@ def create_payload( prompt_map = {prompt["id"]: prompt["name"] for prompt in prompts} + # The dataplane proxies streamable-HTTP upstreams only; other + # transports (e.g. SSE, deprecated and removed in the 2026-07-28 + # MCP protocol update) are excluded so the published config never + # advertises a backend the dataplane cannot serve. Backends are + # keyed by the gateway slug, not the id: the dataplane uses the + # key as the namespace prefix for federated tool names, so slug + # keys make it advertise the same names the control plane does + # (e.g. "fast-time-echo" instead of "-echo"). gateway_base = { - gateway["id"]: { + (gateway.get("slug") or gateway["id"]): { "name": gateway["name"], "url": gateway["url"], "transport": gateway["transport"], "passthrough_headers": gateway["passthrough_headers"] or [], } for gateway in gateways + if (gateway["transport"] or "").upper() == "STREAMABLEHTTP" } + gateway_key_by_id = {gateway["id"]: (gateway.get("slug") or gateway["id"]) for gateway in gateways} virtual_hosts: dict[str, VirtualHostConfig] = {} @@ -227,7 +245,8 @@ def create_payload( backends: dict[str, BackendConfig] = {} for gateway_id, backend_items in server["backend_items"].items(): - gateway_config = gateway_base.get(gateway_id) + gateway_key = gateway_key_by_id.get(gateway_id) + gateway_config = gateway_base.get(gateway_key) if gateway_key else None if gateway_config is None: continue @@ -236,13 +255,20 @@ def create_payload( if not backend_items["tools"] and not allowed_resource_names and not allowed_prompt_names: continue - backends[gateway_id] = { + backends[gateway_key] = { **gateway_config, "allowed_tool_names": backend_items["tools"], "allowed_resource_names": allowed_resource_names, "allowed_prompt_names": allowed_prompt_names, } + if not backends: + # No publishable backends: leave the virtual host out so + # the dataplane returns 404 for it and deployments can + # route the request back to the control plane, instead of + # serving an empty tool list that looks like success. + continue + virtual_hosts[server["id"]] = {"backends": backends} result[user_email] = {"virtual_hosts": virtual_hosts} @@ -273,6 +299,7 @@ async def get_data_from_db(self) -> dict[str, Any] | None: gateway_rows = db.execute( select( DbGateway.id, + DbGateway.slug, DbGateway.name, DbGateway.url, DbGateway.transport, @@ -338,6 +365,7 @@ def _build_user_data( "gateways": [ { "id": gateway.id, + "slug": gateway.slug, "name": gateway.name, "url": gateway.url, "transport": gateway.transport, diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index 8be96f1304..65885a38d0 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -2460,7 +2460,21 @@ async def read_resource( # it internally checks which uri matches the pattern of modified uri and fetches # the one which matches else raises ResourceNotFoundError try: - content = await self._read_template_resource(db, uri) or None + from mcpgateway.auth_context import get_user_email # pylint: disable=import-outside-toplevel + + template_user_email = None if user is None else get_user_email(user) + content = ( + await self._read_template_resource( + db, + uri, + include_inactive=include_inactive, + user_email=template_user_email, + token_teams=token_teams, + server_id=server_id, + use_cache=True, + ) + or None + ) # ═══════════════════════════════════════════════════════════════════════════ # SECURITY: Fetch the template's DbResource record for access checking # _read_template_resource returns ResourceContent with the template's ID @@ -2539,6 +2553,25 @@ async def read_resource( # ResourceContents covers TextResourceContents and BlobResourceContents (MCP-compliant) # ResourceContent is the legacy model for backwards compatibility + def _set_gateway_content(content_obj: Any, attr_name: str, resource_response: Any) -> None: + """Apply gateway content or reject unresolved template placeholders.""" + if resource_response is not None: + setattr(content_obj, attr_name, resource_response) + return + + template_uri = getattr(resource_db, "uri_template", None) if resource_db else None + requested_uri = uri if uri is not None else original_uri + placeholder = getattr(content_obj, attr_name, None) + content_uri = getattr(content_obj, "uri", None) + if template_uri and requested_uri and placeholder is not None and content_uri is not None and str(placeholder) == str(requested_uri) and str(content_uri) == str(template_uri): + logger.warning( + "Resource template proxy read returned no content for requested URI '%s' from template '%s' via gateway '%s'", + requested_uri, + template_uri, + getattr(resource_db_gateway, "id", None) or getattr(resource_db, "gateway_id", None), + ) + raise ResourceError(f"Resource template '{template_uri}' did not resolve URI '{requested_uri}'") + if isinstance(content, (ResourceContent, ResourceContents, TextContent)): # Metrics are recorded in read_resource finally block for all resources resource_response = await self.invoke_resource( @@ -2552,8 +2585,7 @@ async def read_resource( gateway_obj=resource_db_gateway, server_id=server_id, ) - if resource_response: - setattr(content, "text", resource_response) + _set_gateway_content(content, "text", resource_response) # If content is any object that quacks like content elif hasattr(content, "text") or hasattr(content, "blob"): # Metrics are recorded in read_resource finally block for all resources @@ -2569,8 +2601,7 @@ async def read_resource( gateway_obj=resource_db_gateway, server_id=server_id, ) - if resource_response: - setattr(content, "blob", resource_response) + _set_gateway_content(content, "blob", resource_response) elif hasattr(content, "text"): resource_response = await self.invoke_resource( db, @@ -2583,8 +2614,7 @@ async def read_resource( gateway_obj=resource_db_gateway, server_id=server_id, ) - if resource_response: - setattr(content, "text", resource_response) + _set_gateway_content(content, "text", resource_response) # Normalize primitive types to ResourceContent elif isinstance(content, bytes): content = ResourceContent(type="resource", id=str(resource_id), uri=original_uri, blob=content) @@ -3757,7 +3787,16 @@ def _detect_mime_type(self, uri: str, content: Union[str, bytes]) -> str: return "application/octet-stream" - async def _read_template_resource(self, db: Session, uri: str, include_inactive: Optional[bool] = False) -> ResourceContent: + async def _read_template_resource( + self, + db: Session, + uri: str, + include_inactive: Optional[bool] = False, + user_email: Optional[str] = None, + token_teams: Optional[List[str]] = None, + server_id: Optional[str] = None, + use_cache: bool = True, + ) -> ResourceContent: """ Read a templated resource. @@ -3765,6 +3804,10 @@ async def _read_template_resource(self, db: Session, uri: str, include_inactive: db: Database session. uri: Template URI with parameters. include_inactive: Whether to include inactive resources in DB lookups. + user_email: Email of the requesting user for scoped template lookup. + token_teams: Teams from the request token for scoped template lookup. + server_id: Optional virtual server ID for scoped template lookup. + use_cache: Whether to reuse the unscoped template cache. Returns: ResourceContent: The resolved content from the matching template. @@ -3776,12 +3819,22 @@ async def _read_template_resource(self, db: Session, uri: str, include_inactive: """ # Find matching template # DRT BREAKPOINT template = None - if not self._template_cache: - logger.info("_template_cache is empty, fetching exisitng resource templates") - resource_templates = await self.list_resource_templates(db=db, include_inactive=include_inactive) - for i in resource_templates: - self._template_cache[i.name] = i - for cached in self._template_cache.values(): + scoped_lookup = server_id is not None or user_email is not None or token_teams is not None + if not use_cache or scoped_lookup or not self._template_cache: + logger.info("Fetching resource templates for template URI resolution") + resource_templates = await self.list_resource_templates( + db=db, + include_inactive=include_inactive, + user_email=user_email, + token_teams=token_teams, + server_id=server_id, + ) + if use_cache and not scoped_lookup: + for i in resource_templates: + self._template_cache[i.name] = i + else: + resource_templates = list(self._template_cache.values()) + for cached in resource_templates: if self._uri_matches_template(uri, cached.uri_template): template = cached break diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index 835251fb2d..ca71482712 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -85,7 +85,7 @@ from mcpgateway.services.oauth_manager import OAuthEnforcementUnavailableError, OAuthRequiredError from mcpgateway.services.permission_service import PermissionService from mcpgateway.services.prompt_service import PromptService -from mcpgateway.services.resource_service import ResourceService +from mcpgateway.services.resource_service import ResourceError, ResourceNotFoundError, ResourceService from mcpgateway.services.tool_service import ToolService from mcpgateway.transports.context import UserContext from mcpgateway.transports.redis_event_store import RedisEventStore @@ -2652,6 +2652,8 @@ async def read_resource(resource_uri: str) -> Union[str, bytes]: token_teams=token_teams, meta_data=meta_data, ) + except (ResourceError, ResourceNotFoundError): + raise except Exception as e: logger.exception("Error reading resource '%s': %s", resource_uri, e) return "" @@ -2667,6 +2669,8 @@ async def read_resource(resource_uri: str) -> Union[str, bytes]: # No content found logger.warning("No content returned by resource: %s", resource_uri) return "" + except (ResourceError, ResourceNotFoundError): + raise except Exception as e: logger.exception("Error reading resource '%s': %s", resource_uri, e) return "" diff --git a/tests/live_gateway/mcp/test_mcp_rbac_transport.py b/tests/live_gateway/mcp/test_mcp_rbac_transport.py index 6a5d2cf590..2452b89688 100644 --- a/tests/live_gateway/mcp/test_mcp_rbac_transport.py +++ b/tests/live_gateway/mcp/test_mcp_rbac_transport.py @@ -68,6 +68,8 @@ # Must match docker-compose gateway JWT_SECRET_KEY _JWT_SECRET = os.getenv("JWT_SECRET_KEY", "my-test-key-but-now-longer-than-32-bytes") _CLIENT_TIMEOUT = float(os.getenv("MCP_E2E_CLIENT_TIMEOUT", "5.0")) +_PER_SERVER_ACCESS_SYNC_DEADLINE_SECONDS = 75.0 +_PER_SERVER_ACCESS_RETRY_DELAY_SECONDS = 1.0 # --------------------------------------------------------------------------- @@ -465,6 +467,17 @@ def _mcp_tools_list(access_token: str, server_url: str = BASE_URL) -> list: return _run_async(_async_mcp_tools_list(access_token, server_url)) +def _mcp_tools_list_after_publisher_sync(access_token: str, server_url: str = BASE_URL) -> list: + deadline = time.monotonic() + _PER_SERVER_ACCESS_SYNC_DEADLINE_SECONDS + while True: + try: + return _mcp_tools_list(access_token, server_url=server_url) + except Exception: + if time.monotonic() >= deadline: + raise + time.sleep(_PER_SERVER_ACCESS_RETRY_DELAY_SECONDS) + + def _mcp_resources_list(access_token: str, server_url: str = BASE_URL) -> list: return _run_async(_async_mcp_resources_list(access_token, server_url)) @@ -783,16 +796,18 @@ def test_public_token_accesses_public_server(self, outsider_user: dict, visibili """Outsider can access the public server's per-server MCP endpoint.""" server_id = visibility_servers["public"]["id"] server_url = f"{BASE_URL}/servers/{server_id}" - tools = _mcp_tools_list(outsider_user["access_token"], server_url=server_url) + tools = _mcp_tools_list_after_publisher_sync(outsider_user["access_token"], server_url=server_url) # May see only that server's tools print(f" -> Outsider via /servers/{server_id}/mcp: {len(tools)} tools") + assert tools, "public server should advertise its associated tools, not an empty list" def test_team_member_accesses_team_server(self, test_users: dict, visibility_servers: dict) -> None: """Developer can access the team server's per-server endpoint.""" server_id = visibility_servers["team"]["id"] server_url = f"{BASE_URL}/servers/{server_id}" - tools = _mcp_tools_list(test_users["developer"]["access_token"], server_url=server_url) + tools = _mcp_tools_list_after_publisher_sync(test_users["developer"]["access_token"], server_url=server_url) print(f" -> Developer via /servers/{server_id}/mcp: {len(tools)} tools") + assert tools, "team server should advertise its associated tools, not an empty list" def test_outsider_denied_team_server(self, outsider_user: dict, visibility_servers: dict) -> None: """Outsider cannot access team server's per-server endpoint.""" diff --git a/tests/live_gateway/protocol_compliance/fixtures/upstream_registration.py b/tests/live_gateway/protocol_compliance/fixtures/upstream_registration.py index 4dc84a7315..d1334d0c95 100644 --- a/tests/live_gateway/protocol_compliance/fixtures/upstream_registration.py +++ b/tests/live_gateway/protocol_compliance/fixtures/upstream_registration.py @@ -64,6 +64,43 @@ def _wait_for_federation_sync( return [], "federation produced no tools for this gateway within timeout (endpoint healthy)" +_PER_SERVER_ROUTE_SYNC_DEADLINE_SECONDS = 75.0 +_PER_SERVER_ROUTE_SYNC_RETRY_DELAY_SECONDS = 1.0 + + +def _wait_for_per_server_route(client: httpx.Client, server_id: str) -> None: + """Wait until the per-server MCP route accepts the new virtual server. + + On split deployments the ``/servers/{id}/mcp`` route is served from + dataplane config that ContextForge publishes asynchronously + (``DATAPLANE_PUBLISHER_INTERVAL_SECONDS``, default 60s), so a freshly + created server can return 400/404 until the next snapshot lands. + Control-plane-only deployments answer immediately, so this returns on + the first probe there. The deadline covers one full default publish + interval plus slack; on timeout it warns and returns so the tests + surface the failure with their own diagnostics. + """ + payload = { + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "fixture-probe", "version": "0"}}, + } + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + deadline = time.monotonic() + _PER_SERVER_ROUTE_SYNC_DEADLINE_SECONDS + while True: + resp = client.post(f"/servers/{server_id}/mcp/", json=payload, headers=headers) + if resp.status_code not in (400, 404): + return + if time.monotonic() >= deadline: + warnings.warn( + f"per-server MCP route for {server_id} still returns {resp.status_code} after {_PER_SERVER_ROUTE_SYNC_DEADLINE_SECONDS}s; continuing so tests report the failure", + stacklevel=2, + ) + return + time.sleep(_PER_SERVER_ROUTE_SYNC_RETRY_DELAY_SECONDS) + + def _delete_if_exists(client: httpx.Client, path: str, name: str) -> None: """Best-effort delete-by-name — tolerates prior crashed-run leftovers. @@ -123,7 +160,7 @@ def virtual_server( """Create a virtual server composing the reference server's tools.""" tools, diag = _wait_for_federation_sync(gateway_http_client, registered_reference_upstream["id"]) if not tools: - pytest.skip(f"federation sync did not surface any tools for gateway " f"{registered_reference_upstream['id']}: {diag}") + pytest.skip(f"federation sync did not surface any tools for gateway {registered_reference_upstream['id']}: {diag}") _delete_if_exists(gateway_http_client, "/servers", "compliance_virtual") # POST /servers takes ServerCreate nested under a top-level "server" key @@ -139,6 +176,7 @@ def virtual_server( if resp.status_code not in (200, 201): pytest.skip(f"virtual-server creation failed {resp.status_code}: {resp.text[:200]}") server = resp.json() + _wait_for_per_server_route(gateway_http_client, server["id"]) try: yield server finally: diff --git a/tests/unit/mcpgateway/services/test_dataplane_publisher.py b/tests/unit/mcpgateway/services/test_dataplane_publisher.py index ac17034fb7..47cd4076c2 100644 --- a/tests/unit/mcpgateway/services/test_dataplane_publisher.py +++ b/tests/unit/mcpgateway/services/test_dataplane_publisher.py @@ -164,9 +164,10 @@ async def test_full_payload_generation_with_mock_db(): gateway1 = Mock() gateway1.id = "g1" + gateway1.slug = "gateway-1" gateway1.name = "Gateway 1" gateway1.url = "http://localhost:9000" - gateway1.transport = "sse" + gateway1.transport = "STREAMABLEHTTP" gateway1.passthrough_headers = ["Authorization"] gateway1.owner_email = "user1@example.com" gateway1.team_id = "team1" @@ -259,12 +260,12 @@ async def test_full_payload_generation_with_mock_db(): # Verify backend configuration server1 = user1_config["virtual_hosts"]["s1"] assert "backends" in server1 - assert "g1" in server1["backends"] + assert "gateway-1" in server1["backends"], "backends must be keyed by gateway slug" - backend = server1["backends"]["g1"] + backend = server1["backends"]["gateway-1"] assert backend["name"] == "Gateway 1" assert backend["url"] == "http://localhost:9000" - assert backend["transport"] == "sse" + assert backend["transport"] == "STREAMABLEHTTP" assert backend["passthrough_headers"] == ["Authorization"] assert backend["allowed_tool_names"] == ["public_tool", "private_tool"] assert backend["allowed_resource_names"] == ["Resource 1"] @@ -273,15 +274,17 @@ async def test_full_payload_generation_with_mock_db(): # Verify user2 sees public server but not private server from user1 user2_config = payload["user2@example.com"] assert "s1" in user2_config["virtual_hosts"] # public - assert "s2" in user2_config["virtual_hosts"] # own private - user2_backend = user2_config["virtual_hosts"]["s1"]["backends"]["g1"] + # Own private server exists but has no backend associations, so it + # is omitted from the payload (no publishable backends). + assert "s2" not in user2_config["virtual_hosts"] + user2_backend = user2_config["virtual_hosts"]["s1"]["backends"]["gateway-1"] assert user2_backend["allowed_tool_names"] == ["public_tool", "team2_tool"] # Verify active users with no team membership still get public-only config. user3_config = payload["user3@example.com"] assert "s1" in user3_config["virtual_hosts"] assert "s2" not in user3_config["virtual_hosts"] - user3_backend = user3_config["virtual_hosts"]["s1"]["backends"]["g1"] + user3_backend = user3_config["virtual_hosts"]["s1"]["backends"]["gateway-1"] assert user3_backend["allowed_tool_names"] == ["public_tool"] @@ -364,7 +367,7 @@ def test_create_payload_filters_empty_backends(): }, } ], - "gateways": [{"id": "gateway1", "name": "Gateway 1", "url": "http://localhost:9000", "transport": "sse", "passthrough_headers": None}], + "gateways": [{"id": "gateway1", "name": "Gateway 1", "url": "http://localhost:9000", "transport": "STREAMABLEHTTP", "passthrough_headers": None}], "prompts": [], "resources": [], } @@ -372,9 +375,36 @@ def test_create_payload_filters_empty_backends(): result = service.create_payload(data) - # Server exists but has no backends (all empty) - assert "server1" in result["user@example.com"]["virtual_hosts"] - assert result["user@example.com"]["virtual_hosts"]["server1"]["backends"] == {} + # A server with no publishable backends is omitted entirely so the + # dataplane 404s it instead of serving an empty tool list. + assert "server1" not in result["user@example.com"]["virtual_hosts"] + + +def test_create_payload_excludes_non_streamable_gateways(): + """create_payload() drops backends whose transport the dataplane cannot serve.""" + from mcpgateway.services.dataplane_publisher import DataplanePublisherService + + service = DataplanePublisherService() + data = { + "user@example.com": { + "servers": [ + { + "id": "server1", + "backend_items": { + "gateway_sse": {"tools": ["tool1"], "resources": [], "prompts": []}, + }, + } + ], + "gateways": [{"id": "gateway_sse", "name": "SSE Gateway", "url": "http://localhost:9000/sse", "transport": "SSE", "passthrough_headers": None}], + "prompts": [], + "resources": [], + } + } + + result = service.create_payload(data) + + # The SSE backend is excluded and the now-backendless server is omitted. + assert result["user@example.com"]["virtual_hosts"] == {} def test_create_payload_normalizes_null_passthrough_headers(): @@ -392,7 +422,7 @@ def test_create_payload_normalizes_null_passthrough_headers(): }, } ], - "gateways": [{"id": "gateway1", "name": "Gateway 1", "url": "http://localhost:9000", "transport": "sse", "passthrough_headers": None}], + "gateways": [{"id": "gateway1", "name": "Gateway 1", "url": "http://localhost:9000", "transport": "STREAMABLEHTTP", "passthrough_headers": None}], "prompts": [], "resources": [], } @@ -428,8 +458,9 @@ def test_create_payload_handles_missing_references(): result = service.create_payload(data) # Server exists but has no backends (gateway missing) - assert "server1" in result["user@example.com"]["virtual_hosts"] - assert result["user@example.com"]["virtual_hosts"]["server1"]["backends"] == {} + # With its only gateway missing, the server has no publishable backends + # and is omitted from the payload. + assert "server1" not in result["user@example.com"]["virtual_hosts"] @pytest.mark.asyncio @@ -549,7 +580,7 @@ async def test_publish_writes_payload_releases_lock_and_exits_when_shutdown_wait """publish_to_redis() writes msgpack payloads and releases the worker lock.""" import msgpack - from mcpgateway.services.dataplane_publisher import PUBLISHER_LOCK_KEY, PUBLISHER_TTL, USER_CONFIG_KEY, WORKER_ID, DataplanePublisherService + from mcpgateway.services.dataplane_publisher import PUBLISHER_LOCK_KEY, PUBLISHER_TTL, USER_CONFIG_KEY, DataplanePublisherService service = DataplanePublisherService() payload = {"user@example.com": {"virtual_hosts": {"server1": {"backends": {}}}}} @@ -581,7 +612,7 @@ async def _finish_cycle(awaitable, timeout): assert pipe.set.call_args.kwargs == {"ex": PUBLISHER_TTL} pipe.execute.assert_awaited_once() mock_redis.eval.assert_awaited_once() - assert mock_redis.eval.await_args.args[1:] == (1, PUBLISHER_LOCK_KEY, WORKER_ID) + assert mock_redis.eval.await_args.args[1:] == (1, PUBLISHER_LOCK_KEY, service.worker_id) mock_wait_for.assert_awaited_once() diff --git a/tests/unit/mcpgateway/services/test_resource_service.py b/tests/unit/mcpgateway/services/test_resource_service.py index 139ae6f836..c40b5f984a 100644 --- a/tests/unit/mcpgateway/services/test_resource_service.py +++ b/tests/unit/mcpgateway/services/test_resource_service.py @@ -1846,7 +1846,13 @@ async def test_read_template_resource_error(self): # Create a valid ResourceTemplate object template_obj = ResourceTemplate( - id="1", uriTemplate="test://template/{id}", name="template", description="Test template", mime_type="text/plain", annotations=None, _meta=None # alias for uri_template + id="1", + uriTemplate="test://template/{id}", + name="template", + description="Test template", + mime_type="text/plain", + annotations=None, + _meta=None, # alias for uri_template ) # Pre-load template cache @@ -1857,7 +1863,6 @@ async def test_read_template_resource_error(self): # Patch match + extraction to force an error with patch.object(service, "_uri_matches_template", return_value=True), patch.object(service, "_extract_template_params", side_effect=Exception("Template error")): - # Assert failure path with pytest.raises(ResourceError) as exc_info: await service._read_template_resource(db, uri) @@ -1892,7 +1897,6 @@ async def test_read_template_resource_binary_not_supported(self): service._template_cache = {"binary": template} with patch.object(service, "_uri_matches_template", return_value=True), patch.object(service, "_extract_template_params", return_value={"id": "123"}): - with pytest.raises(ResourceError) as exc_info: await service._read_template_resource(db, uri) @@ -5592,19 +5596,258 @@ async def test_read_resource_template_access_check_include_inactive_true_skips_e template_db.owner_email = None template_db.team_id = None - # 1) URI lookup miss, 2) inactivity check miss, 3) template access-check fetch - db.execute.return_value.scalar_one_or_none.side_effect = [None, None, template_db] + # 1) URI lookup miss, 2) inactivity check miss, 3) inactive template miss, 4) template access-check fetch + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, None, template_db] content = ResourceContent(type="resource", id="tmpl-1", uri="greetme://morning/{name}", text="greetme://morning/John") with ( patch.object(svc, "_read_template_resource", new_callable=AsyncMock, return_value=content), patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), - patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value=None), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value="resolved template"), patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), ): out = await svc.read_resource(db, resource_uri="greetme://morning/John", include_inactive=True) assert out.id == "tmpl-1" + assert out.text == "resolved template" + + @pytest.mark.asyncio + async def test_read_resource_template_scoped_lookup_ignores_stale_cache(self): + """Scoped template reads fetch fresh templates instead of using stale cache entries.""" + # First-Party + from mcpgateway.common.models import ResourceTemplate + from mcpgateway.services.resource_service import ResourceService + + svc = ResourceService() + stale_template = ResourceTemplate( + id="stale-tmpl", + uriTemplate="reference://users/{user_id}", + name="users", + description=None, + mime_type="text/plain", + ) + fresh_template = ResourceTemplate( + id="fresh-tmpl", + uriTemplate="reference://users/{user_id}", + name="users", + description=None, + mime_type="text/plain", + ) + svc._template_cache = {"users": stale_template} + + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "fresh-tmpl" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + # 1) URI lookup miss, 2) inactivity check miss, 3) inactive template miss, 4) template access-check fetch + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, None, template_db] + + with ( + patch.object(svc, "list_resource_templates", new_callable=AsyncMock, return_value=[fresh_template]) as list_templates, + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value='{"user_id":"7","name":"User 7"}'), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + ): + out = await svc.read_resource(db, resource_uri="reference://users/7", user="user@example.com", token_teams=[]) + + list_templates.assert_awaited_once() + assert out.id == "fresh-tmpl" + assert out.text == '{"user_id":"7","name":"User 7"}' + + @pytest.mark.asyncio + async def test_read_resource_template_unscoped_lookup_uses_existing_cache(self): + """Unscoped template reads reuse cached templates.""" + # First-Party + from mcpgateway.common.models import ResourceTemplate + from mcpgateway.services.resource_service import ResourceService + + svc = ResourceService() + cached_template = ResourceTemplate( + id="cached-tmpl", + uriTemplate="reference://users/{user_id}", + name="users", + description=None, + mime_type="text/plain", + ) + svc._template_cache = {"users": cached_template} + + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "cached-tmpl" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + # 1) URI lookup miss, 2) inactivity check miss, 3) cached-template inactivity check miss, 4) template access-check fetch + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, None, template_db] + + with ( + patch.object(svc, "list_resource_templates", new_callable=AsyncMock, return_value=[]) as list_templates, + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value='{"user_id":"7","name":"Cached User"}'), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + ): + out = await svc.read_resource(db, resource_uri="reference://users/7") + + list_templates.assert_not_awaited() + assert out.id == "cached-tmpl" + assert out.text == '{"user_id":"7","name":"Cached User"}' + + @pytest.mark.asyncio + async def test_read_resource_template_proxy_none_response_raises(self): + """Templated proxy reads fail when gateway resolution returns no content.""" + # First-Party + from mcpgateway.common.models import ResourceContent + from mcpgateway.services.resource_service import ResourceError, ResourceService + + svc = ResourceService() + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "tmpl-1" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, template_db] + content = ResourceContent(type="resource", id="tmpl-1", uri="reference://users/{user_id}", text="reference://users/7") + + with ( + patch.object(svc, "_read_template_resource", new_callable=AsyncMock, return_value=content), + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value=None), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + ): + with pytest.raises(ResourceError, match="did not resolve URI"): + await svc.read_resource(db, resource_uri="reference://users/7") + + @pytest.mark.asyncio + async def test_read_resource_template_proxy_allows_empty_text_response(self): + """Templated proxy reads preserve an intentionally empty text response.""" + # First-Party + from mcpgateway.common.models import ResourceContent + from mcpgateway.services.resource_service import ResourceService + + svc = ResourceService() + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "tmpl-1" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, template_db] + content = ResourceContent(type="resource", id="tmpl-1", uri="reference://users/{user_id}", text="reference://users/7") + + with ( + patch.object(svc, "_read_template_resource", new_callable=AsyncMock, return_value=content), + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value=""), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + ): + out = await svc.read_resource(db, resource_uri="reference://users/7") + + assert out.text == "" + + @pytest.mark.asyncio + async def test_read_resource_template_proxy_blob_none_response_raises(self, caplog): + """Templated proxy blob reads fail when gateway resolution returns no content.""" + # Standard + from types import SimpleNamespace + + # First-Party + from mcpgateway.services.resource_service import ResourceError, ResourceService + + svc = ResourceService() + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "tmpl-1" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, template_db] + content = SimpleNamespace(id="tmpl-1", uri="reference://users/{user_id}", blob="reference://users/7") + + with ( + patch.object(svc, "_read_template_resource", new_callable=AsyncMock, return_value=content), + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value=None), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + caplog.at_level("WARNING", logger="mcpgateway.services.resource_service"), + ): + with pytest.raises(ResourceError, match="did not resolve URI"): + await svc.read_resource(db, resource_uri="reference://users/7") + + assert "Resource template proxy read returned no content" in caplog.text + + @pytest.mark.asyncio + async def test_read_resource_template_proxy_allows_empty_blob_response(self): + """Templated proxy blob reads preserve an intentionally empty response.""" + # Standard + from types import SimpleNamespace + + # First-Party + from mcpgateway.services.resource_service import ResourceService + + svc = ResourceService() + db = MagicMock() + db.commit = MagicMock() + + template_db = MagicMock() + template_db.id = "tmpl-1" + template_db.uri = "reference://users/{user_id}" + template_db.uri_template = "reference://users/{user_id}" + template_db.enabled = True + template_db.visibility = "public" + template_db.owner_email = None + template_db.team_id = None + template_db.gateway_id = "gateway-1" + + db.execute.return_value.scalar_one_or_none.side_effect = [None, None, template_db] + content = SimpleNamespace(id="tmpl-1", uri="reference://users/{user_id}", blob="reference://users/7") + + with ( + patch.object(svc, "_read_template_resource", new_callable=AsyncMock, return_value=content), + patch.object(svc, "_check_resource_access", new_callable=AsyncMock, return_value=True), + patch.object(svc, "invoke_resource", new_callable=AsyncMock, return_value=b""), + patch("mcpgateway.services.metrics_buffer_service.get_metrics_buffer_service", return_value=MagicMock()), + ): + out = await svc.read_resource(db, resource_uri="reference://users/7") + + assert out.blob == b"" @pytest.mark.asyncio async def test_read_resource_resource_id_fallback_include_inactive_true_bytes_content_records_metric_failure(self): diff --git a/tests/unit/mcpgateway/test_wrapper.py b/tests/unit/mcpgateway/test_wrapper.py index d275472536..62b02860e2 100644 --- a/tests/unit/mcpgateway/test_wrapper.py +++ b/tests/unit/mcpgateway/test_wrapper.py @@ -356,12 +356,16 @@ def fake_readline(): except StopIteration: return b"" + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + # Mock buffer.readline if available, else stdin.readline (but existing test ran on host python which likely has buffer) # The wrapper uses sys.stdin.buffer.readline if available. if hasattr(sys.stdin, "buffer"): monkeypatch.setattr(sys.stdin.buffer, "readline", fake_readline) else: monkeypatch.setattr(sys.stdin, "readline", fake_readline) + monkeypatch.setattr(wrapper.asyncio, "to_thread", fake_to_thread) task = asyncio.create_task(wrapper.stdin_reader(q)) diff --git a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py index 9eb9613d6c..f4e8be8993 100644 --- a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py +++ b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py @@ -1817,6 +1817,30 @@ async def fake_get_db(): assert "Error reading resource 'file:///error.txt': service error!" in caplog.text +@pytest.mark.asyncio +async def test_read_resource_service_resource_error_propagates(monkeypatch): + """Resource service failures propagate as MCP-level resource errors.""" + # Third-Party + from pydantic import AnyUrl + + # First-Party + from mcpgateway.services.resource_service import ResourceError + from mcpgateway.transports.streamablehttp_transport import read_resource, resource_service + + mock_db = MagicMock() + + @asynccontextmanager + async def fake_get_db(): + yield mock_db + + monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", fake_get_db) + monkeypatch.setattr(resource_service, "read_resource", AsyncMock(side_effect=ResourceError("template did not resolve"))) + + test_uri = AnyUrl("file:///template.txt") + with pytest.raises(ResourceError, match="template did not resolve"): + await read_resource(test_uri) + + @pytest.mark.asyncio async def test_read_resource_outer_exception(monkeypatch, caplog): """Test read_resource returns empty string and logs exception from outer try-catch."""