Astro
Direct mode works with output: "static" — no adapter, no server. Astro's <script> in a .astro component is already bundled and client-side, so there is nothing to hydrate.
Client script
---
// src/components/WebMcpInit.astro
---
<script>
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcp, isWebMcpSupported } from "@timbenniks/contentstack-webmcp";
import { contentTypes } from "../content-types";
if (isWebMcpSupported()) {
const stack = contentstack.stack({
apiKey: import.meta.env.PUBLIC_CONTENTSTACK_API_KEY,
deliveryToken: import.meta.env.PUBLIC_CONTENTSTACK_DELIVERY_TOKEN,
environment: import.meta.env.PUBLIC_CONTENTSTACK_ENVIRONMENT,
});
const webmcp = createContentstackWebMcp({ mode: "direct", stack, contentTypes });
const controller = new AbortController();
await webmcp.register({
signal: controller.signal,
navigate: (path) => window.location.assign(path),
});
window.addEventListener("pagehide", () => controller.abort());
}
</script>Drop it into your layout:
---
import WebMcpInit from "../components/WebMcpInit.astro";
---
<html>
<body>
<slot />
<WebMcpInit />
</body>
</html>WARNING
No client:load here. Client directives only apply to framework components (React, Vue, Svelte). On a .astro component they are an error — and unnecessary, since its <script> already runs in the browser.
Env vars
Astro only exposes PUBLIC_-prefixed variables to client code. That prefix is the mode boundary:
# .env — direct mode: the browser needs these
PUBLIC_CONTENTSTACK_API_KEY=...
PUBLIC_CONTENTSTACK_DELIVERY_TOKEN=... # read-only
PUBLIC_CONTENTSTACK_ENVIRONMENT=production
# proxy mode: server-only, no prefix
CONTENTSTACK_API_KEY=...
CONTENTSTACK_DELIVERY_TOKEN=...
CONTENTSTACK_ENVIRONMENT=productionOn Astro 5, astro:env gives you the same split with type safety — declare the delivery token as access: "public" for direct mode, "secret" for proxy.
View Transitions
With <ClientRouter />, a .astro <script> runs once on first load and not again after a transition — which is what you want, since tools stay registered. But pagehide does not fire on transitions either, so nothing is torn down until a real navigation. That is correct behaviour here.
If you moved init into an astro:page-load listener, guard against re-registering:
document.addEventListener("astro:page-load", () => {
if (registered) return;
registered = true;
// …register once
});Proxy mode
Needs output: "server" and an adapter (Node, Vercel, Netlify, Cloudflare).
// src/pages/api/contentstack/[...path].ts
import type { APIRoute } from "astro";
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcpProxyHandler } from "@timbenniks/contentstack-webmcp/server";
import { contentTypes } from "../../../content-types";
const stack = contentstack.stack({
apiKey: import.meta.env.CONTENTSTACK_API_KEY,
deliveryToken: import.meta.env.CONTENTSTACK_DELIVERY_TOKEN,
environment: import.meta.env.CONTENTSTACK_ENVIRONMENT,
});
const handler = createContentstackWebMcpProxyHandler({
stack,
contentTypes,
basePath: "/api/contentstack",
customRoutes: {
"/featured": async (_request, { stack: cda }) => {
const result = await cda
.contentType("page")
.entry()
.query()
.where("featured", "$eq", true)
.find();
return Response.json({ data: result.entries ?? [] });
},
},
});
export const GET: APIRoute = ({ request }) => handler(request);Then in the client script, drop the stack and switch to { mode: "proxy", proxyBasePath: "/api/contentstack", contentTypes }.
Framework islands
If your interactive islands are React, you can use the /react hook inside one instead of a .astro script — mount it with client:load in that case, since it is a framework component.
Permissions-Policy
Static output has no server, so set it at your host — Netlify _headers, Vercel vercel.json, nginx. See Server runtimes.