Svelte / SvelteKit
No adapter needed. onMount returns its own cleanup function, which lines up exactly with aborting the signal.
Svelte 5 (runes)
<!-- src/lib/WebMcp.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcp, isWebMcpSupported } from "@timbenniks/contentstack-webmcp";
import { goto } from "$app/navigation";
import { PUBLIC_CONTENTSTACK_API_KEY, PUBLIC_CONTENTSTACK_DELIVERY_TOKEN, PUBLIC_CONTENTSTACK_ENVIRONMENT } from "$env/static/public";
import { contentTypes } from "$lib/content-types";
let registered = $state<string[]>([]);
onMount(() => {
if (!isWebMcpSupported()) return;
const controller = new AbortController();
const stack = contentstack.stack({
apiKey: PUBLIC_CONTENTSTACK_API_KEY,
deliveryToken: PUBLIC_CONTENTSTACK_DELIVERY_TOKEN,
environment: PUBLIC_CONTENTSTACK_ENVIRONMENT,
});
const webmcp = createContentstackWebMcp({ mode: "direct", stack, contentTypes });
webmcp
.register({ signal: controller.signal, navigate: (path) => goto(path) })
.then((tools) => (registered = tools));
// onMount's return value runs on destroy.
return () => controller.abort();
});
</script>Svelte 4 is identical minus the rune — use let registered: string[] = [].
Mount it once
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import WebMcp from "$lib/WebMcp.svelte";
let { children } = $props();
</script>
<WebMcp />
{@render children()}In the root layout it mounts once and survives client-side navigation, so tools register a single time rather than on every route change.
TIP
goto from $app/navigation keeps navigate_to a client-side transition. Passing location.assign instead would trigger a full page reload and re-register everything.
Env vars
SvelteKit only exposes variables prefixed PUBLIC_ to the browser, via $env/static/public. That is exactly the boundary you want: direct mode needs PUBLIC_ vars, proxy mode uses $env/static/private on the server and the browser gets nothing.
# .env
PUBLIC_CONTENTSTACK_API_KEY=...
PUBLIC_CONTENTSTACK_DELIVERY_TOKEN=... # read-only, direct mode only
PUBLIC_CONTENTSTACK_ENVIRONMENT=productionProxy mode
SvelteKit server routes already speak Web Request/Response, so the handler drops straight in:
// src/routes/api/contentstack/[...path]/+server.ts
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcpProxyHandler } from "@timbenniks/contentstack-webmcp/server";
import {
CONTENTSTACK_API_KEY,
CONTENTSTACK_DELIVERY_TOKEN,
CONTENTSTACK_ENVIRONMENT,
} from "$env/static/private";
import { contentTypes } from "$lib/content-types";
const stack = contentstack.stack({
apiKey: CONTENTSTACK_API_KEY,
deliveryToken: CONTENTSTACK_DELIVERY_TOKEN,
environment: CONTENTSTACK_ENVIRONMENT,
});
const handler = createContentstackWebMcpProxyHandler({ stack, contentTypes });
export const GET = ({ request }) => handler(request);Then in the component, drop the stack:
createContentstackWebMcp({ mode: "proxy", proxyBasePath: "/api/contentstack", contentTypes });Permissions-Policy
// src/hooks.server.ts
export async function handle({ event, resolve }) {
const response = await resolve(event);
response.headers.set("Permissions-Policy", "tools=(self)");
return response;
}Plain Svelte (no Kit)
Use the Vite env vars (import.meta.env.VITE_*) and location.assign for navigate, or your router's equivalent. Everything else is unchanged — see Vanilla JS / HTML.