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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions agentic/docs/project-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<img crossorigin>` — 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 `<img>` tags
are unaffected.

## Tech Stack

- **Backend**: FastAPI, SQLAlchemy (async), PostgreSQL, Python 3.13+
Expand Down
11 changes: 9 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 42 additions & 20 deletions api/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -233,25 +237,31 @@ 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:
await session.close()


@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

Expand All @@ -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
Comment thread
MarkusNeusinger marked this conversation as resolved.

impl_response = ImplementationResponse(
library_id=impl.library.id,
Expand All @@ -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(
Expand All @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down
36 changes: 35 additions & 1 deletion api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
"<h1>anyplot MCP server</h1>"
"<p>Query the plot catalogue from an AI assistant via the Model Context "
"Protocol. Transport: Streamable HTTP at "
'<a href="https://api.anyplot.ai/mcp/">https://api.anyplot.ai/mcp/</a> '
"(POST JSON-RPC; the endpoint requires an MCP client and answers plain GET "
"with an error by design). No authentication, read-only.</p>"
"<h2>Setup</h2>"
"<pre><code>claude mcp add --transport http anyplot https://api.anyplot.ai/mcp/</code></pre>"
"<h2>Tools</h2>"
"<ul>"
"<li><code>list_specs(limit, offset)</code> — all plot specifications with tags and library counts</li>"
"<li><code>search_specs_by_tags(plot_type, data_type, domain, features, library, ...)</code>"
" — filter the catalogue by spec- and implementation-level tags</li>"
"<li><code>get_spec_detail(spec_id, libraries?)</code> — one spec with its implementations"
" (full source code; filter by library ids to keep the payload small)</li>"
"<li><code>get_implementation(spec_id, library)</code> — one implementation: runnable code,"
" light/dark render URLs, quality score, review data</li>"
"<li><code>list_libraries()</code> — the supported plotting libraries</li>"
"<li><code>get_tag_values(category)</code> — the vocabulary of any tag category</li>"
"</ul>"
"<p>Prefer plain HTTP? The same catalogue is served by the "
'<a href="https://api.anyplot.ai/openapi.json">JSON API</a> and indexed in '
'<a href="https://anyplot.ai/llms-full.txt">llms-full.txt</a>; retrieval '
'recipes live in <a href="https://anyplot.ai/llms.txt">llms.txt</a>.</p>'
)


@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,
)
)

Expand Down
Loading
Loading