Every public report at /v/<slug> has a matching /embed/<slug>
that strips the chrome and CORS-allows everything. Drop it in an iframe, or pull the underlying tiles
and config straight into your own MapLibre instance. Same data either way.
Any public report is embeddable as-is. No API key. CORS is open.
<iframe
src="https://mapsmcp.com/embed/your-report-slug"
width="100%"
height="640"
style="border:0; border-radius:8px"
loading="lazy"
allow="fullscreen"
></iframe>
That's it — works on any domain, no allowlist required.
Append query params to /embed/<slug> to change theme, hide chrome, focus on a single shape, or auto-size the iframe.
<!-- Same map, dark theme, no legend, autosizing iframe, zoomed to a county -->
<iframe
src="https://mapsmcp.com/embed/your-report-slug?theme=dark&hide_legend=1&autosize=1&scope=us-counties:tiger-2024:48201"
width="100%"
style="border:0; border-radius:8px"
></iframe>
<script>
// If you set ?autosize=1 the viewer postMessages its content height. Listen
// for it and size the iframe so the embed has no fixed height.
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'mapsmcp:height') {
const iframe = document.querySelector('iframe[src*="/embed/" + e.data.slug + "/"], iframe[src*="/embed/' + e.data.slug + '?"]');
if (iframe) iframe.style.height = e.data.px + 'px';
}
});
</script>
See the full param reference below.
One-line script tag, then call MapsMCP.embed({ slug, container, … }). Under the hood it builds an iframe pointed at /embed/<slug>, but it also wires up the postMessage event protocol so hover and click events surface as plain JS callbacks. No iframe sizing math, no manual event listeners.
<!-- Drop the SDK in any page; works on any domain. -->
<script src="https://mapsmcp.com/mapsmcp.js"></script>
<div id="map" style="height: 480px"></div>
<script>
const embed = MapsMCP.embed({
slug: 'your-report-slug',
container: '#map',
theme: 'dark',
// Hover/click events are emitted by the viewer regardless of options;
// the SDK just forwards them to your callbacks.
onHover: (e) => {
// e = { type, slug, feature: {...properties} | null, lngLat: [lng, lat] }
if (e.feature) statusBar.textContent = e.feature.name;
},
onClick: (e) => {
// e.feature is the same shape as a vector-tile feature properties bag.
window.location.href = '/feature/' + e.feature.GEOID;
},
});
// Later, switch the active dataset without remounting the iframe:
embed.update({ dataset: 'your-dataset-slug' });
// Tear down (e.g. on route change in a SPA):
embed.destroy();
</script>
Options:
| Option | Type | Description |
|---|---|---|
slug | string (required) | The report slug. |
container | string CSS selector or Element | Where to insert the iframe. |
theme | "light" · "dark" | Color theme for the embedded viewer. |
scope | string | Focus on a single shape, e.g. "us-cd119:tiger-2024:4812". |
dataset | string | Override the default layer (slug or field). |
hideLegend · hideTitle · hideTooltip | boolean | Strip chrome. |
autosize | boolean (default true) | Resize iframe to content via postMessage. Set false if you want fixed height. |
height | number or CSS length | Fixed iframe height (ignored when autosize is on). |
onHover(e) | function | Called on hover. e.feature is the properties bag, or null on mouse-out. e.lngLat is [lng, lat]. |
onClick(e) | function | Called on click. Same payload shape as onHover. |
Returned handle: { destroy(), update(patch), iframe, slug }.
The autosize protocol means you never have to guess at an iframe height — the viewer posts { type: 'mapsmcp:height', px } on init and on any resize.
// React/Next.js component — drop into any page.
export function MapsMCPEmbed({ slug, scope, theme = 'light', hideLegend = false }) {
const ref = React.useRef(null);
React.useEffect(() => {
const onMessage = (e) => {
if (e.data && e.data.type === 'mapsmcp:height' && e.data.slug === slug) {
if (ref.current) ref.current.style.height = e.data.px + 'px';
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [slug]);
const params = new URLSearchParams({ autosize: '1', theme });
if (scope) params.set('scope', scope);
if (hideLegend) params.set('hide_legend', '1');
return (
<iframe
ref={ref}
src={`https://mapsmcp.com/embed/${slug}?${params.toString()}`}
style={{ width: '100%', border: 0, borderRadius: 8 }}
loading="lazy"
/>
);
}
// Usage
<MapsMCPEmbed slug="your-report-slug" theme="dark" hideLegend />
When iframes aren't enough — you need custom hover handlers, layered overlays, your own tooltip — bring the tiles + config into your own MapLibre instance.
Vector tiles served via PostGIS ST_AsMVT; the resolved config.json includes the report's breaks, palette, and label fields so the styling matches what you'd see in /v/<slug>.
<!-- Full control: drop our vector tiles into your own MapLibre instance. -->
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" />
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js"></script>
<div id="map" style="width:100%;height:520px"></div>
<script>
(async () => {
// Pull the report's resolved layer config (breaks, colors, palette).
const cfg = await fetch('https://mapsmcp.com/v/your-report-slug/config.json').then(r => r.json());
const layer = cfg.layers[cfg.default_layer_index];
const tileUrl = `https://mapsmcp.com/tiles/${cfg.collection}/${cfg.version}/{z}/{x}/{y}.pbf`;
// Build a MapLibre step expression from the resolved breaks + colors.
const stops = ['step', ['get', layer.field], layer.colors[0]];
if (layer.breaks) for (let i = 0; i < layer.breaks.breaks.length; i++) {
stops.push(layer.breaks.breaks[i], layer.colors[i + 1]);
}
new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {
shapes: { type: 'vector', tiles: [tileUrl], minzoom: 0, maxzoom: 12 },
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': '#f3f4f6' } },
{
id: 'fill', type: 'fill', source: 'shapes', 'source-layer': 'shapes',
paint: { 'fill-color': stops, 'fill-opacity': 0.78 },
},
{
id: 'stroke', type: 'line', source: 'shapes', 'source-layer': 'shapes',
paint: { 'line-color': '#1f2937', 'line-width': 0.3 },
},
],
},
bounds: cfg.bbox,
});
})();
</script>
The tile endpoint is GET /tiles/<collection>/<version>/{z}/{x}/{y}.pbf with a 24-hour immutable cache. Open to all origins.
For places where interactive maps can't survive: email campaigns, Slack/Teams unfurls, Open Graph share previews, PDF scorecards, slide decks.
<!-- For email digests, OG-tag images, PDFs, slide decks. PNG and SVG. -->
<!-- PNG (raster) -->
<img
src="https://mapsmcp.com/v/your-report-slug.png?width=1200&height=720&dpi=2"
alt="Your report title"
width="1200" height="720"
/>
<!-- SVG (vector — infinite-resolution, smaller for choropleths) -->
<img
src="https://mapsmcp.com/v/your-report-slug.svg?width=1200&height=720"
alt="Your report title"
/>
<!-- Open Graph tags — auto-rich-previews when sharing your app URL -->
<meta property="og:image" content="https://mapsmcp.com/v/your-report-slug.png?width=1200&height=630" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
Rendered server-side via d3-geo + @resvg/resvg-js — pure WASM, no headless browser. Cached 5 minutes at the edge so OG-tag scrapers don't hammer the renderer.
For reports backed by sensitive data — internal whip counts, donor concentration, pre-publication scorecards — you don't want the report world-readable, but you do want to embed it in your own app. Set config.embed_origins on a public: false report to allowlist specific origins:
// MCP call — make a private report embeddable from your app's origin(s) only.
// The report is NOT visible at /v/<slug> on a direct browser visit; it only
// renders when an iframe (or fetch) on an allowed origin loads it.
create_report({
slug: "internal-territory-map",
name: "Internal territory map — confidential",
public: false, // not world-public
config: {
version: "tiger-2024",
collection: "us-counties",
datasets: [{
slug: "territory-assignments-q3",
field: "status",
palette: "categorical",
categories: { "active": "#16a34a", "at_risk": "#dc2626", "growing": "#86efac", "stable": "#fca5a5", "unassigned": "#9ca3af" }
}],
embed_origins: [
"https://app.yourbrand.com",
"https://staging.yourbrand.com"
]
}
});
// Then on app.yourbrand.com:
// <iframe src="https://mapsmcp.com/embed/internal-territory-map">
// 200 + the map.
//
// From anywhere else (different host, direct browser visit, curl with no
// Referer): HTTP 403. The slug existence isn't leaked from non-allowed
// origins because we serve the 403 with the same path regardless.
Behavior:
Origin (cross-origin fetches) or Referer (iframe HTML loads) that matches the allowlist → 200. Response carries Access-Control-Allow-Origin: <matched-origin> (not *), so only that origin's JS can read the JSON.public: true reports ignore this list. Public means truly public.scheme://host[:port] — no paths, no trailing slashes. create_report validates this and rejects malformed entries.This is origin-based gating, not authentication — it leans on the same Origin/Referer headers browsers already use for CORS. Don't rely on it for high-security material; for that, use signed URLs (queued) or keep the report fully private and proxy through your own backend.
Mapbox basemap tokens and isochrone provider keys can be stored once per workspace and referenced by name — no more pasting pk.eyJ… into every report config or tool call. Three MCP tools manage them:
set_workspace_secret({ name, value }) — store / upsert.list_workspace_secrets() — list the names (values never returned).delete_workspace_secret({ name }) — remove.// 1. Store the Mapbox token once.
set_workspace_secret({ name: "mapbox", value: "pk.eyJ1Ijoi..." });
// 2. Reference it from any report (instead of pasting the raw token).
create_report({
slug: "median-income-by-county",
name: "Median household income — U.S. counties (ACS 2023)",
public: true,
config: {
tiles: true,
version: "tiger-2024",
collection: "us-counties",
shape_overlays: [{ field: "B19013_001E", palette: "sequential" }],
mapbox_token_secret: "mapbox", // <-- resolved server-side
mapbox_style: "mapbox/light-v11"
}
});
// 3. Same for geo_isochrone — store the provider key once, use everywhere.
set_workspace_secret({ name: "mapbox-isochrone", value: "pk.eyJ1Ijoi..." });
geo_isochrone({
point: [-77.005, 38.890],
minutes: [10, 20, 30],
provider: "mapbox",
api_key_secret: "mapbox-isochrone" // <-- instead of api_key: "..."
});
// 4. Rotate without touching reports: just set_workspace_secret again
// with the same name and a new value. Every report that references it
// picks up the new token on next viewer load.
How it resolves:
mapbox_token_secret: "name" resolves at viewer-config build time and the token gets injected into the served HTML. If the secret is missing, the viewer falls back gracefully (flat grey background) instead of erroring.api_key_secret instead of api_key. Missing-secret errors are explicit so callers can fix them.^[a-z][a-z0-9_-]*$, max 64 chars.get tool exists. Values can only be USED (by reference), not read back via the API. This is intentional — same property BYOK encryption will preserve in Sprint D.| Param | Values | Applies to | Effect |
|---|---|---|---|
theme | light (default) · dark | /v · /embed | Dark background + panel/legend colors. Useful when embedding inside dark UIs. |
hide_title | 1 | /v · /embed | Removes the title panel (top-left). |
hide_legend | 1 | /v · /embed | Removes the legend (bottom-left). |
hide_tooltip | 1 | /v · /embed | Suppresses the hover tooltip. Useful for non-interactive screenshots. |
bare | 1 | /v | Shortcut for hide_title + hide_footer. Implied by /embed/. |
dataset | slug or field name | /v · /embed · .png · .svg | Overrides the report's default layer when it has multiple datasets/overlays. |
scope | collection:version:external_id | /v · /embed | Focuses on a single shape (e.g. us-cd119:tiger-2024:4812 for TX-12). Useful for per-district detail pages. |
autosize | 1 | /embed | Viewer posts { type: 'mapsmcp:height', slug, px } to window.parent on init + resize so the iframe can grow with content. |
width / height | 200–4000 px | .png · .svg | Image render dimensions. Default 1024 × 640. |
dpi | 1–4 | .png | Raster density multiplier. 2 = retina-quality. |
Questions? Open a ticket via the submit_ticket MCP tool, or email [email protected].