Skip to content

Experimental · Unofficial

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

Cloudflare Workers

Workers host the proxy handler. If you only want direct mode, you need no Worker at all — see Vanilla JS / HTML.

Worker

typescript
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcpProxyHandler } from "@timbenniks/contentstack-webmcp/server";
import { contentTypes } from "./content-types";

interface Env {
  CONTENTSTACK_API_KEY: string;
  CONTENTSTACK_DELIVERY_TOKEN: string;
  CONTENTSTACK_ENVIRONMENT: string;
}

type Handler = (request: Request) => Promise<Response>;
let handler: Handler | undefined;

// `env` only exists inside fetch, so build on first request and reuse
// the handler for the lifetime of the isolate.
function getHandler(env: Env): Handler {
  if (!handler) {
    const stack = contentstack.stack({
      apiKey: env.CONTENTSTACK_API_KEY,
      deliveryToken: env.CONTENTSTACK_DELIVERY_TOKEN,
      environment: env.CONTENTSTACK_ENVIRONMENT,
    });

    handler = createContentstackWebMcpProxyHandler({
      stack,
      contentTypes,
      basePath: "/api/contentstack",
    });
  }

  return handler;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return getHandler(env)(request);
  },
};

WARNING

Do not call contentstack.stack({ apiKey: env.… }) at module top level. In Workers, env is only bound inside fetch — at module scope it is undefined, and the Worker throws on first request.

Wrangler config

toml
# wrangler.toml
name = "contentstack-webmcp-proxy"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[vars]
CONTENTSTACK_ENVIRONMENT = "production"

# Secrets, not vars:
#   wrangler secret put CONTENTSTACK_API_KEY
#   wrangler secret put CONTENTSTACK_DELIVERY_TOKEN

Custom routes

typescript
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 ?? [] });
    },
  },
});

Expose it to agents with a custom tool that fetches /api/contentstack/featured — see Custom CDA queries.

Pages + Worker

Host the static frontend on Cloudflare Pages and the proxy on a Worker route at /api/contentstack/*. The browser then needs no credentials:

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

Permissions-Policy

The header belongs on the document response, not the proxy's JSON responses. With Pages, use a _headers file:

txt
/*
  Permissions-Policy: tools=(self)

If the Worker also serves HTML, set it there:

typescript
const response = await getHandler(env)(request);
response.headers.set("Permissions-Policy", "tools=(self)");
return response;

Released under the MIT License.