Skip to content

Experimental · Unofficial

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

Custom CDA queries ​

Three paths for custom Contentstack queries, from simplest to most integrated.

1. createCdaClient() — SDK access for custom tools ​

Best for custom tool execute handlers with full Delivery SDK access.

typescript
import { QueryOperation } from "@contentstack/delivery-sdk";
import { createCdaClient, createContentstackWebMcp } from "@timbenniks/contentstack-webmcp";

const cda = createCdaClient({ stack, contentTypes });

// Built-in helpers
await cda.search({ query: "headless" });
await cda.getByUrl("/blog/my-post");
await cda.getByUid("blogpost", "blt123");
await cda.listContentTypes();

// Custom tool with raw SDK query
await webmcp.registerCustomTools(
  [
    {
      name: "products_by_sku",
      description: "Find products by SKU prefix",
      inputSchema: {
        type: "object",
        properties: { sku: { type: "string" } },
        required: ["sku"],
      },
      annotations: { readOnlyHint: true, untrustedContentHint: true },
      execute: async (input) => {
        const result = await stack
          .contentType("product")
          .entry()
          .query()
          .where("sku", QueryOperation.STARTS_WITH, input.sku)
          .find();
        return { data: result.entries };
      },
    },
  ],
  { signal }
);

Where to use ​

ContextUse createCdaClient
Server custom routeYes — pass server stack
Direct mode browser toolYes — pass client stack
Proxy mode browser toolNo — call your proxy/customRoutes instead

2. customRoutes — custom proxy endpoints ​

Add bespoke GET routes alongside built-in proxy endpoints.

typescript
import { createContentstackWebMcpProxyHandler, ok } from "@timbenniks/contentstack-webmcp/server";

const handler = createContentstackWebMcpProxyHandler({
  stack,
  contentTypes,
  basePath: "/api/contentstack",
  customRoutes: {
    "/products/by-sku": async (request, { stack, contentTypes, maxLimit }) => {
      const sku = new URL(request.url).searchParams.get("sku");
      const result = await stack
        .contentType("product")
        .entry()
        .query()
        .where("sku", QueryOperation.EQUALS, sku)
        .find();
      return ok({ data: result.entries ?? [] });
    },
    "/featured": async (_request, { stack }) => {
      const result = await stack.contentType("page").entry().find();
      const featured = (result.entries ?? []).filter((e) => e.featured);
      return ok({ data: featured });
    },
  },
});

Expose to agents via a custom tool:

typescript
{
  name: "get_product_by_sku",
  execute: async (input) => {
    const res = await fetch(`/api/contentstack/products/by-sku?sku=${input.sku}`);
    return res.json();
  },
}

Custom routes are matched before built-in routes. Keys are paths after basePath, e.g. '/products/by-sku'.

Context object ​

typescript
interface WebMcpProxyContext {
  stack: ContentstackStack;
  contentTypes: ContentTypeWebMcpConfig[];
  locale?: string;
  maxLimit: number;
}

3. contentTypes[].serialize — transform output ​

Shape responses from built-in tools without changing query logic:

typescript
{
  uid: "blogpost",
  label: "Blog Post",
  urlField: "url",
  titleField: "title",
  serialize: (entry) => ({
    ...entry,
    reading_time: estimateReadingTime(entry),
  }),
}

Decision guide ​

NeedSolution
Arbitrary SDK query in browser (demo)registerCustomTools + stack
Arbitrary SDK query in productioncustomRoutes + custom tool calling fetch
Reuse built-in search/get with extra fieldsserialize
Low-level CDA without WebMCPcreateCdaClient() directly

Released under the MIT License.