Skip to content

Experimental · Unofficial

Built by Tim Benniks. This is not an official Contentstack product and is not supported by Contentstack.

Svelte / SvelteKit

No adapter needed. onMount returns its own cleanup function, which lines up exactly with aborting the signal.

Svelte 5 (runes)

svelte
<!-- 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

svelte
<!-- 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.

bash
# .env
PUBLIC_CONTENTSTACK_API_KEY=...
PUBLIC_CONTENTSTACK_DELIVERY_TOKEN=...     # read-only, direct mode only
PUBLIC_CONTENTSTACK_ENVIRONMENT=production

Proxy mode

SvelteKit server routes already speak Web Request/Response, so the handler drops straight in:

typescript
// 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:

typescript
createContentstackWebMcp({ mode: "proxy", proxyBasePath: "/api/contentstack", contentTypes });

Permissions-Policy

typescript
// 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.

Released under the MIT License.