Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -107,15 +122,15 @@ 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)},
)


async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""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)},
)


Expand All @@ -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)}
)


Expand Down
15 changes: 13 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "*")
Comment thread
MarkusNeusinger marked this conversation as resolved.

# 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"
Expand Down
7 changes: 5 additions & 2 deletions api/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/api/mcp/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
27 changes: 26 additions & 1 deletion tests/unit/api/test_routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/api/test_seo_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([])
Expand Down
Loading