From 3960b026d29e4256ccb34443740a32dbb56bca21 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:44:04 +0200 Subject: [PATCH 1/5] fix(mcp): make every tool's output true for the whole catalogue Targeted MCP pass from the 2026-08-19 AI-access audit: - get_implementation resolves the language from the library's own DB row; the repository's language_id='python' default made all 1,004 R/Julia/JS implementations (28% of the catalogue) answer a false 'not found' - website_url in list_specs / search_specs_by_tags / get_spec_detail pointed at anyplot.ai/python/{spec}, which 301s into a 404; now the hub - get_spec_detail attaches per-implementation website_urls AFTER model_dump - SpecDetailResponse coercion silently dropped them before - and gains an optional 'libraries' filter so a 15-library spec's ~0.5 MB response can be trimmed to the libraries actually asked for - stateless MCP HTTP is now real: passed explicitly to http_app(); the former os.environ.setdefault ran after fastmcp's Settings were instantiated and never engaged, leaving sessions instance-pinned Co-Authored-By: Claude Fable 5 --- api/main.py | 11 ++++- api/mcp/server.py | 60 ++++++++++++++-------- tests/unit/api/mcp/test_tools.py | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 22 deletions(-) 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..a8e37f4ca1 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,21 @@ 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. 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 +280,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 +318,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 +337,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 +352,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 +389,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/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 From fef7b19823ab8dbd290d74c2a994078f2b375907 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:54:36 +0200 Subject: [PATCH 2/5] docs(guide): record the anyplot-images bucket CORS configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CORS policy applied 2026-08-19 (AI-access audit) is bucket metadata, not code — without this runbook entry a bucket rebuild would silently drop it and cross-origin image embedding would break again. Co-Authored-By: Claude Fable 5 --- agentic/docs/project-guide.md | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) 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+ From 62a43b5fbd961d2213b5f6b96f17f56b80afc82e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:58:12 +0200 Subject: [PATCH 3/5] feat(seo): give the bot-served /mcp page its actual content The page whose entire audience is AI agents rendered as title plus a one-line description; it now carries the endpoint URL, a setup snippet, the six tools and fallback pointers (JSON API, llms-full.txt). Includes the changelog entries for the whole MCP pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 24 +++++++++++++++++++++++ api/routers/seo.py | 36 +++++++++++++++++++++++++++++++++- tests/unit/api/test_routers.py | 10 ++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 874779a35b..c61495971e 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. ### 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. +- **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. +- **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. + - **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/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 = ( + "

anyplot MCP server

" + "

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.

" + "

Setup

" + "
claude mcp add --transport http anyplot https://api.anyplot.ai/mcp/
" + "

Tools

" + "
    " + "
  • list_specs(limit, offset) — all plot specifications with tags and library counts
  • " + "
  • search_specs_by_tags(plot_type, data_type, domain, features, library, ...)" + " — filter the catalogue by spec- and implementation-level tags
  • " + "
  • get_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 data
  • " + "
  • list_libraries() — the supported plotting libraries
  • " + "
  • get_tag_values(category) — the vocabulary of any tag category
  • " + "
" + "

Prefer 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/test_routers.py b/tests/unit/api/test_routers.py index 11c4fc3f63..792daa6f12 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -1055,6 +1055,16 @@ 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"): + 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") From 06c3aa71ddf21416f697172440b85b26a31791ec Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:59:01 +0200 Subject: [PATCH 4/5] docs(changelog): reference #10489 in the MCP entries Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61495971e..af376ad6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ aggregate instead: an italic *Catalog* line at the end of the version section an - **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. + implementations actually asked for (#10489). ### Fixed @@ -40,17 +40,17 @@ aggregate instead: an italic *Catalog* line at the end of the version section an `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. + 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. + 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. + 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 From cbf01550397d8ea27b30d1a3a4c404aa763a3f5e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:08:27 +0200 Subject: [PATCH 5/5] fix(mcp): assert all six documented tools; document empty-list filter semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #10489: the /mcp bot-page test now asserts every documented tool name, and the get_spec_detail docstring states that an empty libraries list means 'all' — agents commonly send [] for 'no filter', and answering it with zero implementations would read as a missing spec. Co-Authored-By: Claude Fable 5 --- api/mcp/server.py | 8 +++++--- tests/unit/api/test_routers.py | 9 ++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/api/mcp/server.py b/api/mcp/server.py index a8e37f4ca1..227bf90999 100644 --- a/api/mcp/server.py +++ b/api/mcp/server.py @@ -252,9 +252,11 @@ async def get_spec_detail(spec_id: str, libraries: list[str] | None = None) -> d Args: spec_id: The specification ID (e.g., "scatter-basic") libraries: Optional library ids to include (e.g. ["seaborn", "d3"]). - Omit for all. The full response for a 15-library spec carries every - implementation's complete source (~0.5 MB) — filter when only some - libraries matter. + 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: diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 792daa6f12..2045aa6d77 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -1062,7 +1062,14 @@ def test_seo_mcp_tells_agents_how_to_connect(self, client: TestClient) -> None: 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"): + 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: