diff --git a/CHANGELOG.md b/CHANGELOG.md index af376ad6fc..a3529773f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ aggregate instead: an italic *Catalog* line at the end of the version section an audience is AI agents rendered as title + one-line description; it now carries the endpoint URL, a `claude mcp add` setup snippet, the six tools, and pointers to the JSON API and llms-full.txt for clients without MCP support (#10489). +- **Live-verification follow-ups** — a 49-check production sweep across every access + path (crawler UAs, machine files, REST, images/CORS, JSON-LD, MCP) found four small + leftovers: og cards sent no `Access-Control-Allow-Origin` to foreign origins (the same + in-page-fetch blocker fixed for the GCS bucket — now `*` via the cache-header + middleware); `llms-full.txt` lines now list implementations as `{language}/{library}` + so the render URL is buildable from the file alone; error responses on prerendered + pages no longer echo the internal `/seo-proxy` path prefix; and MCP `serverInfo` now + reports the app version instead of fastmcp's package version (#10490). - **The API host no longer forbids itself to AI agents** — `api.anyplot.ai/robots.txt` served `Disallow: /` (only `/og/` excepted), telling every robots-compliant assistant diff --git a/api/exceptions.py b/api/exceptions.py index 714e1a7f93..c0817cdc27 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -97,6 +97,21 @@ def __init__(self, operation: str, detail: str): # ===== Exception Handlers ===== +def _public_path(request: Request) -> str: + """The path as the client knows it — never this API's internal routing. + + nginx serves crawlers by prepending /seo-proxy to the request URI, so an + error on a proxied page echoed the internal prefix back to the crawler + (`{"path": "/seo-proxy/{slug}"}` on any dead spec URL — live verification + 2026-08-19). Logs keep the full internal path; only the reflected JSON is + translated. + """ + path = request.url.path + if path.startswith("/seo-proxy"): + return path.removeprefix("/seo-proxy") or "/" + return path + + async def anyplot_exception_handler(request: Request, exc: AnyplotException) -> JSONResponse: """Handle AnyplotException and return a standardized JSON response. @@ -107,7 +122,7 @@ async def anyplot_exception_handler(request: Request, exc: AnyplotException) -> logger.error("Database query failed during '%s' on %s: %s", exc.operation, request.url.path, exc.detail) return JSONResponse( status_code=exc.status_code, - content={"status": exc.status_code, "message": exc.message, "path": request.url.path}, + content={"status": exc.status_code, "message": exc.message, "path": _public_path(request)}, ) @@ -115,7 +130,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe """Handle FastAPI HTTPException with standardized format.""" return JSONResponse( status_code=exc.status_code, - content={"status": exc.status_code, "message": exc.detail, "path": request.url.path}, + content={"status": exc.status_code, "message": exc.detail, "path": _public_path(request)}, ) @@ -128,7 +143,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes """ logger.exception("Unhandled exception on %s", request.url.path) return JSONResponse( - status_code=500, content={"status": 500, "message": "Internal server error", "path": request.url.path} + status_code=500, content={"status": 500, "message": "Internal server error", "path": _public_path(request)} ) diff --git a/api/main.py b/api/main.py index 2a84230566..8121899b48 100644 --- a/api/main.py +++ b/api/main.py @@ -200,12 +200,23 @@ async def add_cache_headers(request: Request, call_next): """Add Cache-Control headers to API responses for better browser caching.""" response: Response = await call_next(request) + path = request.url.path + + # The og cards are public images consumed cross-origin (link previews, + # chat UIs embedding via fetch). The global CORSMiddleware answers only the + # site's own origins, so foreign origins received no ACAO header at all — + # the same in-page-fetch blocker the GCS bucket CORS fix removed (live + # verification 2026-08-19). setdefault keeps CORSMiddleware's own header + # when the request came from an allowlisted origin. Placed BEFORE the + # method/status guard: an error response without the header is opaque to a + # cross-origin caller, which cannot even read that it was a 404. + if path.startswith("/og/"): + response.headers.setdefault("Access-Control-Allow-Origin", "*") + # Skip for non-GET requests or error responses if request.method != "GET" or response.status_code >= 400: return response - path = request.url.path - # Static data — changes only on deploy (10 min cache, 1h stale-while-revalidate) if path in ("/libraries", "/languages", "/stats"): response.headers["Cache-Control"] = "public, max-age=600, stale-while-revalidate=3600" diff --git a/api/mcp/server.py b/api/mcp/server.py index 227bf90999..7953bcdd09 100644 --- a/api/mcp/server.py +++ b/api/mcp/server.py @@ -12,6 +12,7 @@ from sqlalchemy.pool import NullPool from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem +from api.version import APP_VERSION from core.database import ImplRepository, LibraryRepository, SpecRepository, is_db_configured @@ -72,8 +73,10 @@ async def get_mcp_db_session() -> AsyncSession: # otherwise (verified live: initialize returned an mcp-session-id and requests # without it got HTTP 400; AI-access audit 2026-08-19). -# Initialize FastMCP server -mcp_server = FastMCP("anyplot") +# Initialize FastMCP server. Without an explicit version, serverInfo reports +# the fastmcp PACKAGE version — clients saw "3.4.5" while /health said the app +# is 3.1.0 (live verification 2026-08-19). +mcp_server = FastMCP("anyplot", version=APP_VERSION) @mcp_server.tool() diff --git a/api/routers/seo.py b/api/routers/seo.py index efc0f887ed..0da9caedcc 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -751,7 +751,8 @@ def _build_llms_full(specs: list) -> str: "# anyplot — full catalogue index", "#", "# One line per plot specification:", - "# spec_id | title | hub page | implemented libraries", + "# spec_id | title | hub page | implementations as {language}/{library}", + "# (plug an implementation's {language}/{library} straight into the render URL below)", "#", "# Retrieval recipes (any HTTP client, no crawler user agent needed):", "# source code: https://api.anyplot.ai/specs/{spec_id}/{library}/code", @@ -761,8 +762,12 @@ def _build_llms_full(specs: list) -> str: "# OpenAPI: https://api.anyplot.ai/openapi.json - MCP endpoint: https://api.anyplot.ai/mcp/", "", ] + # {language}/{library} rather than the bare library id: the render URL + # template needs both segments, and without the language every consumer + # had to make a second /specs/{id} call just to build an image URL + # (live verification 2026-08-19). for spec in sorted((s for s in specs if s.impls), key=lambda s: s.id): - libraries = ",".join(sorted(i.library_id for i in spec.impls)) + libraries = ",".join(sorted(f"{i.language_id}/{i.library_id}" for i in spec.impls)) title = " ".join((spec.title or spec.id).split()) lines.append(f"{spec.id} | {title} | https://anyplot.ai/{spec.id} | {libraries}") return "\n".join(lines) + "\n" diff --git a/tests/unit/api/mcp/test_tools.py b/tests/unit/api/mcp/test_tools.py index 5b576a3dd8..f344925934 100644 --- a/tests/unit/api/mcp/test_tools.py +++ b/tests/unit/api/mcp/test_tools.py @@ -497,6 +497,13 @@ class TestMcpServerProtocol: so breaking changes in the fastmcp API surface will be caught. """ + def test_server_reports_the_app_version(self): + """serverInfo must carry the app version, not fastmcp's package version.""" + from api.mcp.server import mcp_server + from api.version import APP_VERSION + + assert mcp_server.version == APP_VERSION + @pytest.mark.asyncio async def test_all_tools_registered(self): """MCP server should have all 6 tools registered.""" diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 2045aa6d77..2f9fab4e74 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -70,6 +70,7 @@ def mock_spec(): """Create a mock spec with implementation.""" mock_impl = MagicMock() mock_impl.library_id = "matplotlib" + mock_impl.language_id = "python" mock_impl.library = MagicMock() mock_impl.library.name = "Matplotlib" mock_impl.library.language = "python" @@ -649,7 +650,9 @@ def test_llms_full_txt_lists_the_catalogue(self, db_client, mock_spec) -> None: with patch("api.routers.seo.SpecRepository", return_value=mock_spec_repo): response = client.get("/llms-full.txt") assert response.status_code == 200 - assert "scatter-basic | Basic Scatter Plot | https://anyplot.ai/scatter-basic | matplotlib" in response.text + assert ( + "scatter-basic | Basic Scatter Plot | https://anyplot.ai/scatter-basic | python/matplotlib" in response.text + ) def test_sitemap_structure(self, client: TestClient) -> None: """Sitemap should return valid XML structure.""" @@ -865,6 +868,10 @@ def test_seo_spec_overview_not_found(self, db_client) -> None: with patch("api.routers.seo.SpecRepository", return_value=mock_spec_repo): response = client.get("/seo-proxy/nonexistent-spec") assert response.status_code == 404 + # The error body must echo the PUBLIC path, not this router's + # internal /seo-proxy prefix (crawlers were shown the internal + # routing on every dead spec URL). + assert response.json()["path"] == "/nonexistent-spec" def test_seo_spec_language_redirects_to_hub(self, client: TestClient) -> None: """Language-overview URL should 301-redirect to the cross-language hub. @@ -1109,6 +1116,24 @@ def test_get_home_og_image(self, client: TestClient) -> None: assert response.headers["content-type"] == "image/png" assert "max-age=86400" in response.headers["cache-control"] + def test_og_images_are_cross_origin_readable(self, client: TestClient) -> None: + """og cards are public images for foreign origins — chat UIs fetching + them in-page got no ACAO at all because the global CORSMiddleware only + answers the site's own origins (live verification 2026-08-19).""" + with patch("api.routers.og_images.track_og_image"): + with patch("api.routers.og_images._get_static_og_image", return_value=b"fake-image"): + response = client.get("/og/home.png", headers={"Origin": "https://chat.openai.com"}) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "*" + + def test_og_error_responses_are_cross_origin_readable_too(self, client: TestClient) -> None: + """Without the header an og 404 is an opaque response — the caller + cannot even read that it was a 404.""" + with patch(DB_CONFIG_PATCH, return_value=False): + response = client.get("/og/no-such-spec.png", headers={"Origin": "https://chat.openai.com"}) + assert response.status_code >= 400 + assert response.headers["access-control-allow-origin"] == "*" + def test_get_home_og_image_with_filters(self, client: TestClient) -> None: """Should pass filter params to tracking.""" with patch("api.routers.og_images.track_og_image") as mock_track: diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index 72071c29ca..037116b6d0 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -41,6 +41,7 @@ def _extract_jsonld(page: str) -> dict: def _mock_impl(library_id: str, language: str, preview: str | None = "https://gcs/preview.png") -> MagicMock: impl = MagicMock() impl.library_id = library_id + impl.language_id = language impl.library = MagicMock() impl.library.language = language impl.preview_url = preview @@ -439,7 +440,9 @@ class TestBuildLlmsFull: def test_one_line_per_spec_with_sorted_libraries(self) -> None: spec = _mock_spec([_mock_impl("seaborn", "python"), _mock_impl("ggplot2", "r")]) text = _build_llms_full([spec]) - assert "scatter-basic | Basic Scatter Plot | https://anyplot.ai/scatter-basic | ggplot2,seaborn" in text + assert ( + "scatter-basic | Basic Scatter Plot | https://anyplot.ai/scatter-basic | python/seaborn,r/ggplot2" in text + ) def test_specs_without_impls_are_skipped(self) -> None: empty = _mock_spec([])