diff --git a/CHANGELOG.md b/CHANGELOG.md
index 874779a35b..af376ad6fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,9 +25,33 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
widths, WebP), OpenAPI and the MCP endpoint. Until now an agent had to walk the 400 KB
sitemap or guess URL shapes; the prerendered pages that carry this information are
user-agent-gated, so most agents never saw them (AI-access audit 2026-08-19) (#10487).
+- **MCP `get_spec_detail` takes a `libraries` filter** — a 15-library spec's full response
+ carries every implementation's complete source (~0.5 MB, a context-window hazard for the
+ agents the tool serves); passing `libraries=["seaborn", "d3"]` now trims it to the
+ implementations actually asked for (#10489).
### Fixed
+- **The MCP server now tells the truth for the whole catalogue** — `get_implementation`
+ called the repository without a language, whose `python` default made all 1,004
+ R/Julia/JavaScript implementations (28% of the catalogue) answer a false "not found";
+ the language now comes from the library's own row. Every discovery tool's `website_url`
+ pointed at `anyplot.ai/python/{spec}`, which 301s into a 404 — now the hub. And
+ `get_spec_detail`'s per-implementation URLs, silently discarded by Pydantic coercion,
+ survive the roundtrip. Stateless HTTP mode is finally real: it is passed explicitly to
+ `http_app()` — the env-var route ran after fastmcp's settings were read and never
+ engaged, leaving MCP sessions pinned to one Cloud Run instance (#10489).
+- **Plot renders embed cross-origin** — the `anyplot-images` GCS bucket sent no CORS
+ headers at all, so chat UIs that fetch image bytes in-page (the way AI assistants
+ inline a remote image) were blocked by the browser even though the objects are public.
+ A read-only bucket CORS policy (`GET`/`HEAD`, any origin) fixes the whole failure
+ class; the policy and re-apply procedure are recorded in the project guide because
+ bucket metadata does not deploy with the repo (#10489).
+- **The bot-served `/mcp` page now says how to connect** — the one page whose entire
+ 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).
+
- **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
that the REST endpoints, `openapi.json` and the MCP transport were off-limits — on the
diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md
index 4d740a668f..3f640d5196 100644
--- a/agentic/docs/project-guide.md
+++ b/agentic/docs/project-guide.md
@@ -349,6 +349,52 @@ gs://anyplot-images/
**Interactive libraries** (generate `.html`): plotly, bokeh, altair, highcharts, pygal, letsplot, chartjs, d3, echarts, muix
**PNG only**: matplotlib, seaborn, plotnine, ggplot2, makie
+### Bucket CORS configuration
+
+The `anyplot-images` bucket serves read-only CORS headers so third-party pages
+and AI chat clients can embed the renders with `fetch`/XHR or
+`` — without them, browsers block cross-origin image reads
+even though the objects are public (applied 2026-08-19, AI-access audit; the
+bucket previously had no CORS configuration at all). This is bucket metadata,
+not code: it does not deploy with the repo, so re-apply it after any bucket
+rebuild.
+
+To apply the configuration:
+
+1. Save the policy as `cors.json`:
+
+ ```json
+ [
+ {
+ "origin": ["*"],
+ "method": ["GET", "HEAD"],
+ "responseHeader": ["Content-Type", "Cache-Control"],
+ "maxAgeSeconds": 3600
+ }
+ ]
+ ```
+
+2. Apply it to the bucket:
+
+ ```bash
+ gcloud storage buckets update gs://anyplot-images --cors-file=cors.json
+ ```
+
+3. Verify that a cross-origin request gets the header:
+
+ ```bash
+ curl -sI -H "Origin: https://example.com" \
+ "https://storage.googleapis.com/anyplot-images/plots/scatter-basic/python/matplotlib/plot-light.png" \
+ | grep -i access-control-allow-origin # expect: *
+ ```
+
+To remove it, run `gcloud storage buckets update gs://anyplot-images --clear-cors`.
+
+Known limitation: GCS cannot send `Cross-Origin-Resource-Policy` headers, so a
+page under `COEP: require-corp` must load the renders with a `crossorigin`
+attribute (or through its own proxy); plain same-tab fetches and `
` tags
+are unaffected.
+
## Tech Stack
- **Backend**: FastAPI, SQLAlchemy (async), PostgreSQL, Python 3.13+
diff --git a/api/main.py b/api/main.py
index b9d702d11c..2a84230566 100644
--- a/api/main.py
+++ b/api/main.py
@@ -56,8 +56,15 @@
logger = logging.getLogger(__name__)
-# Create MCP HTTP app (needed for lifespan integration)
-mcp_http_app = mcp_server.http_app(path="/")
+# Create MCP HTTP app (needed for lifespan integration).
+# stateless_http is passed EXPLICITLY: the env-var route
+# (FASTMCP_STATELESS_HTTP, formerly setdefault'ed in api/mcp/server.py) never
+# engaged because fastmcp's Settings are instantiated at import time, before
+# that line ran. Without stateless mode every MCP session is pinned to the
+# Cloud Run instance that created it — with max-instances=3 and no session
+# affinity, a session's second request could land elsewhere and fail with
+# "Missing session ID" (verified live; AI-access audit 2026-08-19).
+mcp_http_app = mcp_server.http_app(path="/", stateless_http=True)
async def _prewarm_cache() -> None:
diff --git a/api/mcp/server.py b/api/mcp/server.py
index 06026c1c5f..227bf90999 100644
--- a/api/mcp/server.py
+++ b/api/mcp/server.py
@@ -64,9 +64,13 @@ async def get_mcp_db_session() -> AsyncSession:
return _mcp_session_factory()
-# Enable stateless HTTP mode via environment variable (recommended approach)
-# This allows horizontal scaling without session affinity
-os.environ.setdefault("FASTMCP_STATELESS_HTTP", "true")
+# Stateless HTTP mode is passed explicitly to http_app() in api/main.py.
+# The former `os.environ.setdefault("FASTMCP_STATELESS_HTTP", "true")` here
+# never worked: `from fastmcp import FastMCP` above had already instantiated
+# fastmcp's Settings from the environment, so the late setdefault was read by
+# nobody — sessions stayed instance-pinned despite the comment claiming
+# 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")
@@ -105,7 +109,7 @@ async def list_specs(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
item = SpecListItem(
id=spec.id, title=spec.title, description=spec.description, tags=spec.tags, library_count=impl_count
)
- result.append({**item.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/python/{spec.id}"})
+ result.append({**item.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/{spec.id}"})
return result
finally:
@@ -233,7 +237,7 @@ async def search_specs_by_tags(
item = SpecListItem(
id=spec.id, title=spec.title, description=spec.description, tags=spec.tags, library_count=impl_count
)
- result.append({**item.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/python/{spec.id}"})
+ result.append({**item.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/{spec.id}"})
return result
finally:
@@ -241,17 +245,23 @@ async def search_specs_by_tags(
@mcp_server.tool()
-async def get_spec_detail(spec_id: str) -> dict[str, Any]:
+async def get_spec_detail(spec_id: str, libraries: list[str] | None = None) -> dict[str, Any]:
"""
- Get full specification details with all implementations.
+ Get full specification details with implementations.
Args:
spec_id: The specification ID (e.g., "scatter-basic")
+ libraries: Optional library ids to include (e.g. ["seaborn", "d3"]).
+ Omit for all; an empty list also means all (agents commonly send []
+ for "no filter", and answering it with zero implementations would
+ read as a missing spec). The full response for a 15-library spec
+ carries every implementation's complete source (~0.5 MB) — filter
+ when only some libraries matter.
Returns:
Complete spec details including:
- Spec metadata (title, description, tags, etc.)
- - All available implementations with code and metadata
+ - The selected implementations with code and metadata
- Data requirements
- Applications and notes
@@ -272,11 +282,19 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
if spec is None:
raise ValueError(f"Specification '{spec_id}' not found")
- # Build implementations list
+ # Build implementations list. Per-impl website URLs are collected
+ # separately and attached AFTER model_dump below: SpecDetailResponse
+ # coerces its `implementations` into ImplementationResponse, which has
+ # no website_url field — merging the key into the dicts before that
+ # coercion silently discarded it, so the tool returned one broken spec
+ # URL and no per-implementation URLs at all (AI-access audit 2026-08-19).
implementations = []
+ impl_urls: list[str] = []
for impl in spec.impls:
if impl.code is None:
continue
+ if libraries and impl.library.id not in libraries:
+ continue
impl_response = ImplementationResponse(
library_id=impl.library.id,
@@ -302,12 +320,8 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
review_verdict=impl.review_verdict,
impl_tags=impl.impl_tags,
)
- implementations.append(
- {
- **impl_response.model_dump(),
- "website_url": f"{ANYPLOT_WEBSITE_URL}/{spec_id}/{impl.library.language}/{impl.library.id}",
- }
- )
+ implementations.append(impl_response)
+ impl_urls.append(f"{ANYPLOT_WEBSITE_URL}/{spec_id}/{impl.library.language}/{impl.library.id}")
# Build full spec response
response = SpecDetailResponse(
@@ -325,7 +339,10 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
implementations=implementations,
)
- return {**response.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/python/{spec_id}"}
+ payload = response.model_dump()
+ for impl_dict, url in zip(payload["implementations"], impl_urls, strict=True):
+ impl_dict["website_url"] = url
+ return {**payload, "website_url": f"{ANYPLOT_WEBSITE_URL}/{spec_id}"}
finally:
await session.close()
@@ -337,11 +354,13 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
Args:
spec_id: The specification ID (e.g., "scatter-basic")
- library: The library name (e.g., "matplotlib", "seaborn", "plotly")
+ library: The library id (e.g., "matplotlib", "ggplot2", "makie", "d3" —
+ any of the fifteen supported libraries across Python, R, Julia and
+ JavaScript; ids are globally unique, so no language is needed)
Returns:
Implementation details including:
- - Python code
+ - Runnable source code in the library's language
- Quality score
- Library metadata (version, generated date, etc.)
- Preview image URLs
@@ -372,8 +391,11 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
valid_names = [library_obj.id for library_obj in valid_libraries]
raise ValueError(f"Library '{library}' not found. Valid libraries: {', '.join(valid_names)}")
- # Get implementation
- impl = await impl_repo.get_by_spec_and_library(spec_id, library)
+ # Get implementation. The language comes from the library's own DB row —
+ # the repository's language_id defaults to "python", which made every
+ # R/Julia/JavaScript implementation (28% of the catalogue) answer a
+ # false "not found" through this tool (AI-access audit 2026-08-19).
+ impl = await impl_repo.get_by_spec_and_library(spec_id, library, lib.language)
if impl is None or impl.code is None:
raise ValueError(f"Implementation for '{spec_id}' in library '{library}' not found")
diff --git a/api/routers/seo.py b/api/routers/seo.py
index f0225a8c9a..efc0f887ed 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -912,15 +912,49 @@ async def seo_legal():
)
+# The /mcp page's entire audience is AI agents, yet its bot rendering carried
+# only a title and one-line description — no endpoint URL, no tool list, no
+# setup snippet; everything useful sat behind JS in app/src/pages/McpPage.tsx
+# (AI-access audit 2026-08-19). Keep this body in sync with the MCP tools in
+# api/mcp/server.py and the human page in McpPage.tsx.
+_MCP_BOT_BODY = (
+ "
Query the plot catalogue from an AI assistant via the Model Context " + "Protocol. Transport: Streamable HTTP at " + 'https://api.anyplot.ai/mcp/ ' + "(POST JSON-RPC; the endpoint requires an MCP client and answers plain GET " + "with an error by design). No authentication, read-only.
" + "claude mcp add --transport http anyplot https://api.anyplot.ai/mcp/"
+ "list_specs(limit, offset) — all plot specifications with tags and library countssearch_specs_by_tags(plot_type, data_type, domain, features, library, ...)"
+ " — filter the catalogue by spec- and implementation-level tagsget_spec_detail(spec_id, libraries?) — one spec with its implementations"
+ " (full source code; filter by library ids to keep the payload small)get_implementation(spec_id, library) — one implementation: runnable code,"
+ " light/dark render URLs, quality score, review datalist_libraries() — the supported plotting librariesget_tag_values(category) — the vocabulary of any tag categoryPrefer plain HTTP? The same catalogue is served by the " + 'JSON API and indexed in ' + 'llms-full.txt; retrieval ' + 'recipes live in llms.txt.
' +) + + @router.get("/seo-proxy/mcp") async def seo_mcp(): - """Bot-optimized MCP page with correct og:tags.""" + """Bot-optimized MCP page: endpoint, setup snippet and tool list, not just og:tags.""" return HTMLResponse( _render_bot_html( title="MCP Server | anyplot.ai", description="Connect your AI assistant to anyplot via the Model Context Protocol (MCP).", image=DEFAULT_HOME_IMAGE, url="https://anyplot.ai/mcp", + body=_MCP_BOT_BODY, ) ) diff --git a/tests/unit/api/mcp/test_tools.py b/tests/unit/api/mcp/test_tools.py index e52b46f211..5b576a3dd8 100644 --- a/tests/unit/api/mcp/test_tools.py +++ b/tests/unit/api/mcp/test_tools.py @@ -111,6 +111,8 @@ async def test_list_specs(mock_db_context, mock_spec): assert result[0]["id"] == "scatter-basic" assert result[0]["title"] == "Basic Scatter Plot" assert result[0]["library_count"] == 1 + # The hub URL — the former /python/{spec} form 301'd into a 404 + assert result[0]["website_url"] == "https://anyplot.ai/scatter-basic" @pytest.mark.asyncio @@ -241,6 +243,53 @@ async def test_get_spec_detail(mock_db_context, mock_spec): assert len(result["implementations"]) == 1 assert result["implementations"][0]["library_id"] == "matplotlib" assert result["implementations"][0]["code"] == "import matplotlib.pyplot as plt" + # Both URL tiers must survive the Pydantic roundtrip: the spec-level URL is + # the hub (the /python/{spec} form 301'd into a 404), and the per-impl URL + # used to be silently dropped by SpecDetailResponse coercion. + assert result["website_url"] == "https://anyplot.ai/scatter-basic" + assert result["implementations"][0]["website_url"] == "https://anyplot.ai/scatter-basic/python/matplotlib" + + +def _second_impl(library_id: str, language: str) -> MagicMock: + """A second implementation for filter tests, mirroring the fixture's shape.""" + impl = MagicMock() + impl.library.id = library_id + impl.library.name = library_id + impl.library.language = language + impl.code = f"# {library_id} code" + impl.preview_url = None + impl.preview_html = None + impl.preview_url_light = None + impl.preview_url_dark = None + impl.preview_html_light = None + impl.preview_html_dark = None + impl.quality_score = 88 + impl.generated_at = None + impl.generated_by = "claude" + impl.python_version = None + impl.language_version = "4.4.1" + impl.library_version = "3.5.1" + impl.review_strengths = [] + impl.review_weaknesses = [] + impl.review_image_description = None + impl.review_criteria_checklist = None + impl.review_verdict = "APPROVED" + impl.impl_tags = None + return impl + + +@pytest.mark.asyncio +async def test_get_spec_detail_libraries_filter(mock_db_context, mock_spec): + """The optional libraries filter trims a multi-megabyte response to what's asked for.""" + mock_spec.impls = [*mock_spec.impls, _second_impl("ggplot2", "r")] + mock_repo = MagicMock() + mock_repo.get_by_id_with_code = AsyncMock(return_value=mock_spec) + + with patch("api.mcp.server.SpecRepository", return_value=mock_repo): + result = await get_spec_detail("scatter-basic", libraries=["ggplot2"]) + + assert [impl["library_id"] for impl in result["implementations"]] == ["ggplot2"] + assert result["implementations"][0]["website_url"] == "https://anyplot.ai/scatter-basic/r/ggplot2" @pytest.mark.asyncio @@ -283,6 +332,42 @@ async def test_get_implementation(mock_db_context, mock_spec): assert result["library_id"] == "matplotlib" assert result["code"] == "import matplotlib.pyplot as plt" assert result["quality_score"] == 92 + # The language must come from the library's own row, not a python default + mock_impl_repo.get_by_spec_and_library.assert_awaited_once_with("scatter-basic", "matplotlib", "python") + + +@pytest.mark.asyncio +async def test_get_implementation_resolves_non_python_language(mock_db_context, mock_spec): + """ggplot2/makie/d3 etc. must resolve through the library's language. + + The repository defaults language_id to "python", so calling it without the + language made all 1,004 R/Julia/JavaScript implementations answer a false + "not found" through this tool (AI-access audit 2026-08-19). + """ + mock_lib = MagicMock() + mock_lib.id = "ggplot2" + mock_lib.name = "ggplot2" + mock_lib.language = "r" + + mock_impl = _second_impl("ggplot2", "r") + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + mock_lib_repo = MagicMock() + mock_lib_repo.get_by_id = AsyncMock(return_value=mock_lib) + mock_impl_repo = MagicMock() + mock_impl_repo.get_by_spec_and_library = AsyncMock(return_value=mock_impl) + + with ( + patch("api.mcp.server.SpecRepository", return_value=mock_spec_repo), + patch("api.mcp.server.LibraryRepository", return_value=mock_lib_repo), + patch("api.mcp.server.ImplRepository", return_value=mock_impl_repo), + ): + result = await get_implementation("scatter-basic", "ggplot2") + + mock_impl_repo.get_by_spec_and_library.assert_awaited_once_with("scatter-basic", "ggplot2", "r") + assert result["language"] == "r" + assert result["website_url"] == "https://anyplot.ai/scatter-basic/r/ggplot2" @pytest.mark.asyncio diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 11c4fc3f63..2045aa6d77 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -1055,6 +1055,23 @@ def test_seo_about(self, client: TestClient) -> None: assert "og:title" in response.text assert "https://anyplot.ai/about" in response.text + def test_seo_mcp_tells_agents_how_to_connect(self, client: TestClient) -> None: + """The /mcp page's audience is AI agents — the bot body must carry the + endpoint, a setup snippet and the tool list, not just og:tags.""" + response = client.get("/seo-proxy/mcp") + assert response.status_code == 200 + assert "https://api.anyplot.ai/mcp/" in response.text + assert "claude mcp add" in response.text + for tool in ( + "list_specs", + "search_specs_by_tags", + "get_spec_detail", + "get_implementation", + "list_libraries", + "get_tag_values", + ): + assert tool in response.text + def test_seo_palette(self, client: TestClient) -> None: """SEO palette page should return HTML with og:tags.""" response = client.get("/seo-proxy/palette")