diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df5b9b..2f69236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CONTRIBUTING.md` with Contributor License Agreement (CLA) requirement - `benchmarks/semantic_search_bench.py` — latency benchmarks for semantic search across scale tiers (500–10K entries) - **PR label automation** (`pr-labels.yml`): GitHub Actions workflow that automates label transitions — resets to "Awaiting CI" on push, promotes to "Ready for QA" when CI passes, cleans up stale labels when actors pick up tasks +- **Favicon route**: `/favicon.ico` served from both `SecretPathMiddleware` and `HealthMiddleware` so Anthropic's Connectors UI (and other services using Google's favicon service) display the awareness logo instead of a generic globe. Served publicly — no secret path required. ## [0.12.0] - 2026-03-26 diff --git a/pyproject.toml b/pyproject.toml index 868fad9..07c1e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ packages = ["src/mcp_awareness"] [tool.hatch.build.targets.wheel.force-include] "src/mcp_awareness/instructions.md" = "mcp_awareness/instructions.md" +"src/mcp_awareness/favicon.ico" = "mcp_awareness/favicon.ico" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/mcp_awareness/favicon.ico b/src/mcp_awareness/favicon.ico new file mode 100644 index 0000000..6addfdb Binary files /dev/null and b/src/mcp_awareness/favicon.ico differ diff --git a/src/mcp_awareness/middleware.py b/src/mcp_awareness/middleware.py index 362d5e6..e577b59 100644 --- a/src/mcp_awareness/middleware.py +++ b/src/mcp_awareness/middleware.py @@ -14,10 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""ASGI middleware for health checks and secret-path routing.""" +"""ASGI middleware for health checks, favicon, and secret-path routing.""" from __future__ import annotations +import pathlib from collections.abc import Callable from typing import Any @@ -27,6 +28,10 @@ # The health response builder is injected so middleware doesn't depend on server globals. HealthBuilder = Callable[[], dict[str, Any]] +# Favicon bytes loaded once at import time (~15 KB). +_FAVICON_PATH = pathlib.Path(__file__).parent / "favicon.ico" +_FAVICON_BYTES: bytes | None = _FAVICON_PATH.read_bytes() if _FAVICON_PATH.exists() else None + class SecretPathMiddleware: """Rewrite /SECRET/mcp -> /mcp, serve /SECRET/health, reject everything else.""" @@ -44,6 +49,12 @@ def __init__( async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] in ("http", "websocket"): path: str = scope.get("path", "") + # Favicon — served publicly (no secret path required) so external + # services like Google's favicon crawler can fetch it. + if path == "/favicon.ico" and _FAVICON_BYTES is not None: + resp = Response(_FAVICON_BYTES, media_type="image/x-icon") + await resp(scope, receive, send) + return # Health endpoint — served at /SECRET/health if path == f"{self.prefix}/health": health_resp = JSONResponse(self.health_builder()) @@ -69,8 +80,14 @@ def __init__(self, app: ASGIApp, health_builder: HealthBuilder) -> None: self.health_builder = health_builder async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] == "http" and scope.get("path") == "/health": - health_resp = JSONResponse(self.health_builder()) - await health_resp(scope, receive, send) - return + if scope["type"] == "http": + path = scope.get("path", "") + if path == "/health": + health_resp = JSONResponse(self.health_builder()) + await health_resp(scope, receive, send) + return + if path == "/favicon.ico" and _FAVICON_BYTES is not None: + resp = Response(_FAVICON_BYTES, media_type="image/x-icon") + await resp(scope, receive, send) + return await self.app(scope, receive, send) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index e4c0c1a..273f499 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -25,7 +25,11 @@ import pytest from mcp_awareness import server as server_mod -from mcp_awareness.middleware import HealthMiddleware, SecretPathMiddleware +from mcp_awareness.middleware import ( + _FAVICON_BYTES, + HealthMiddleware, + SecretPathMiddleware, +) def _health_builder() -> dict[str, Any]: @@ -112,6 +116,15 @@ async def test_non_secret_path_returns_404(self) -> None: status, _body = await _collect_response(app, scope) assert status == 404 + @pytest.mark.anyio + async def test_favicon_served_without_secret_path(self) -> None: + """/favicon.ico is served publicly without requiring the secret prefix.""" + app = self._make_app() + scope = {"type": "http", "path": "/favicon.ico", "method": "GET"} + status, body = await _collect_response(app, scope) + assert status == 200 + assert body == _FAVICON_BYTES + @pytest.mark.anyio async def test_non_http_scope_passes_through(self) -> None: """Non-HTTP scope (e.g. lifespan) passes through to wrapped app.""" @@ -191,6 +204,15 @@ async def test_other_paths_pass_through(self) -> None: data = json.loads(body) assert data["path"] == "/mcp" + @pytest.mark.anyio + async def test_favicon_served(self) -> None: + """/favicon.ico returns the favicon with correct content type.""" + app = self._make_app() + scope = {"type": "http", "path": "/favicon.ico", "method": "GET"} + status, body = await _collect_response(app, scope) + assert status == 200 + assert body == _FAVICON_BYTES + @pytest.mark.anyio async def test_non_http_scope_passes_through(self) -> None: """Non-HTTP scope passes through to wrapped app."""