From c0cdcc0d32e3e2ac8d86f6aa53df3f778532a209 Mon Sep 17 00:00:00 2001 From: Chris Means Date: Sat, 28 Mar 2026 08:52:10 -0500 Subject: [PATCH] Add /favicon.ico route for connector icon display Anthropic's Connectors UI uses Google's favicon service to show connector icons. Serve the awareness logo at /favicon.ico from both middleware classes so it displays in Claude.ai instead of a generic globe. Route is public (no secret path required) so external crawlers can reach it. - favicon.ico copied into package, included in wheel via force-include - SecretPathMiddleware: serves before secret path check - HealthMiddleware: serves alongside /health - 2 new tests (15 total in test_middleware.py) Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + pyproject.toml | 1 + src/mcp_awareness/favicon.ico | Bin 0 -> 15086 bytes src/mcp_awareness/middleware.py | 27 ++++++++++++++++++++++----- tests/test_middleware.py | 24 +++++++++++++++++++++++- 5 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/mcp_awareness/favicon.ico 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 0000000000000000000000000000000000000000..6addfdbf95fb1d92a17f1407191722dc391eb26d GIT binary patch literal 15086 zcmeI32Xs|cy2tlTr-d3I$Uu-Pp{F;B5f`Z(CcT>vFxo&*Q^`%})8M$Eu_fSMFKyqID8KTrx|5ml0 z`04lm*A}Qt3Q=up+&o@2Y@MKLl0#LcDO@?M5z3zWzvZ+=s;aaoRhtp5>N2BMb$V30 ze(ZK=W0%jL_@jR8=5OyiKed1B82D?y0M{jh`aU%k=~eT^lsi&%rf!-eG-lzpqrW%*r@1le_?t6Toj%kX{?2fB zM?Kx@4u9wBj3}=Uwv1QTc){MzFYG@)e|UTQe~}Zd-a0W={r<&Zk7{FBM~5}M7u5;?;g7bz zj=ry{jqQ8-yfAwqKhz0zgx?Q#@8%21p3UdC>6%JYRNdhhRQ3M3>ix+|m76AbRanD2 zR9GVX?dI@ajODw~-407cu-zIF0&f(GbxV%r+(gx{=sT)bd{L0Ot0Tq_;!92rw#_~io9rl^DbAlr+a+WhKayE00 zXcp%lfqN#`r!jxURahg(QEqU#DJf3V$9natce`nZx zqJC~RVH8$jZsYglxCa`F#-q{j4}!lJ{GH(UxRd{|@Y8moL*b8wXErP^@VTD$-3aR% zXIj(>v`mvNYB}dt!@p7PK`+quQYN;-6gKom;t2n*KR9>?|2O9h9`JX9KLGYYXtbM6 z7==}s+xX@C=!-(oR5S_xG4Kzd|8&1~F3Csu>y9o{H3iQomf)(~IId5HB`gA#$I&{J zigv-j6V5bvQ&190bfiY6aLz{C@05GAdo7Xcc!tO5KN02n@qOg|Rde83Rey9jOi9vz zeCR*j;U56|STxDaCXB)=%x(PM98156K?!IE{87zCDZ_b&e$7RCe~x`xT3U3X${jO1 z+0$Z&Ris8f2+NDeiVD$LbgJAMegNg&w1j7)ta3|uE;=CB-1ij1Zb2(6tWgizZE-^@ z^B(ThR)6y)z0a*;qd)BZ(1@l>#%TCwxY>kJScUm^{=4`)8a;w$%JafKzWI`2SaY#q zK=Y-PzVyq0#&a+CsVRK+ZpZd%vG(-1XDU*ow!)Nyg#W}%bJ&S%#?V8b8$$Mdrk}7! z^tnD{FXs;4G>0FTds^>}%!Yjn?9Vu}r^nP3JUgiI?8|+cijo7w)~yoLfLq0;;myUy z3Gh#b=V3RSFbb>2&o$LhBxAwIxG;`G^dIB>+&`tM*bq(mh~^SwSktAHP}ha6VGXA@ zMAaN#Jhd`E;c0u;l#La(Xbay%CjBOZHqQ9c5VHN_q_Mkxy>;~dUu_;$@B#X8>*#%) z%ja5-+(SRgVEkrQSfefWj42x{cO*Prb8zw0hEp4�LDK%_XKV6wzF4ifJk~PJwfp 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. -"""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."""