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.