From 06bbb47f0da23aa6d95f30f6e1f99cd684c12047 Mon Sep 17 00:00:00 2001 From: haz Date: Thu, 9 Apr 2026 14:06:55 +0200 Subject: [PATCH 1/3] feat: support content chunk updates with dot notation and fix richText mutations - Add nested component updates via dot notation (e.g. image-and-title.title), - Round-trip sibling preservation with contentToInput, - Fix richText mutations to use json format instead of invalid plainText field. --- README.md | 79 ++++++-- src/tools/content.ts | 457 +++++++++++++++++++++++++++++++++++------- tests/content.test.ts | 148 +++++++++++++- 3 files changed, 588 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 80bf1b7..6e5e566 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![license](https://img.shields.io/npm/l/@hayodev/crystallize-mcp.svg)](https://github.com/HayoDev/crystallize-mcp/blob/main/LICENSE) [![node](https://img.shields.io/node/v/@hayodev/crystallize-mcp.svg)](https://npmjs.org/package/@hayodev/crystallize-mcp) -MCP server for [Crystallize](https://crystallize.com) headless commerce. Gives AI agents read access to your catalogue, products, shapes, orders, customers, and tenant config — with deep links back to the Crystallize UI. +MCP server for [Crystallize](https://crystallize.com) headless commerce. Gives AI agents read and write access to your catalogue, products, shapes, orders, customers, and tenant config — with deep links back to the Crystallize UI, dry-run safety for mutations, and PII masking for customer data. Works with Claude Code, Claude Desktop, Cursor, Windsurf, Copilot, and any MCP-compatible client. @@ -162,7 +162,7 @@ Or point your MCP client directly at the built entry point: -## Tools (12) +## Tools (16) ### Catalogue (4 tools) @@ -203,6 +203,56 @@ Or point your MCP client directly at the built entry point: | `list_customers` | Search and list customers with pagination | | `get_customer` | Full customer profile — addresses, meta, external references | +### Content (2 tools, requires auth + write mode) + +| Tool | Description | +| ------------------ | ----------------------------------------------------------------------------------------------------- | +| `create_item` | Create a new item (product, document, or folder) with components | +| `update_component` | Update a single component value — supports nested content chunks via dot notation (e.g. `hero.title`) | + +## Write tools + +Write tools require `CRYSTALLIZE_ACCESS_MODE=write` (or `admin`) and a token with write permissions. + +### Dry-run mode + +Set `CRYSTALLIZE_DRY_RUN=true` to preview mutations without executing them. The response shows exactly what would change — the mutation payload, before/after values, and a deep link to the item: + +```json +"env": { + "CRYSTALLIZE_ACCESS_MODE": "write", + "CRYSTALLIZE_DRY_RUN": "true" +} +``` + +### Example prompts + +**Create an item:** + +> "Create a new blog post under /blog using the article shape with title 'Getting Started'" + +**Update a top-level component:** + +> "Find the item at /products/summer-collection and update its description to 'New summer arrivals'" + +**Update a component inside a content chunk:** + +> "Get the item at /articles/my-post, then update hero.title to 'Updated Headline' and give me the deep link to review the draft" + +**Update with change summary:** + +> "Get the item at /articles/guides/my-guide, update its title component to 'New Guide Title', give me the deep link, and show a table of which fields in the chunk changed vs remained unchanged with before/after values" + +The agent will update the target component in **draft only**, preserve all sibling components in the chunk, and return a summary like: + +| Component | Status | Before | After | +| ----------- | ---------- | ------------------ | ------------------ | +| title | ✏️ Updated | Old Guide Title | New Guide Title | +| image | Unchanged | _(existing image)_ | _(existing image)_ | +| description | Unchanged | _(existing text)_ | _(existing text)_ | + +No publishing happens — you review the change in the Crystallize UI via the deep link and publish when ready. + ## Authentication Catalogue and Discovery tools work without auth — just set `CRYSTALLIZE_TENANT_IDENTIFIER`. @@ -214,15 +264,16 @@ For PIM tools (shapes, tenant info, orders, customers), create an access token a ### Environment variables -| Variable | Required | Description | -| --------------------------------- | -------- | ---------------------------------------------------------------------------------- | -| `CRYSTALLIZE_TENANT_IDENTIFIER` | Yes | Your tenant identifier from `app.crystallize.com/{tenant}` | -| `CRYSTALLIZE_ACCESS_TOKEN_ID` | No | Access token ID for PIM API | -| `CRYSTALLIZE_ACCESS_TOKEN_SECRET` | No | Access token secret (paired with token ID) | -| `CRYSTALLIZE_STATIC_AUTH_TOKEN` | No | Static auth token (alternative to ID/secret pair) | -| `CRYSTALLIZE_ACCESS_MODE` | No | `read` (default), `write`, or `admin` — controls which tools are registered | -| `CRYSTALLIZE_PII_MODE` | No | `full` (default), `masked`, or `none` — controls PII in customer/order responses | -| `CRYSTALLIZE_AUDIT_LOG` | No | Path to write an audit log — `~` is expanded (e.g. `~/.crystallize-mcp/audit.log`) | +| Variable | Required | Description | +| --------------------------------- | -------- | -------------------------------------------------------------------------------------- | +| `CRYSTALLIZE_TENANT_IDENTIFIER` | Yes | Your tenant identifier from `app.crystallize.com/{tenant}` | +| `CRYSTALLIZE_ACCESS_TOKEN_ID` | No | Access token ID for PIM API | +| `CRYSTALLIZE_ACCESS_TOKEN_SECRET` | No | Access token secret (paired with token ID) | +| `CRYSTALLIZE_STATIC_AUTH_TOKEN` | No | Static auth token (alternative to ID/secret pair) | +| `CRYSTALLIZE_ACCESS_MODE` | No | `read` (default), `write`, or `admin` — controls which tools are registered | +| `CRYSTALLIZE_DRY_RUN` | No | `true` to preview write operations without executing — see [Write tools](#write-tools) | +| `CRYSTALLIZE_PII_MODE` | No | `full` (default), `masked`, or `none` — controls PII in customer/order responses | +| `CRYSTALLIZE_AUDIT_LOG` | No | Path to write an audit log — `~` is expanded (e.g. `~/.crystallize-mcp/audit.log`) | ### PII mode (opt-in) @@ -252,7 +303,7 @@ Set `CRYSTALLIZE_AUDIT_LOG` to an absolute file path to enable structured loggin } ``` -One JSON line per call — timestamp, tool name, params, result (`ok`/`error`), and tenant. Response content is never logged. +One JSON line per call — timestamp, tool name, params, result (`ok`/`error`), and tenant. Write tools also log mutation metadata (before/after state) for audit trails. > **Note:** Params are logged as-is and may contain PII — for example, a `searchTerm` of `hani@example.com` or a `customerIdentifier`. Treat the audit log file as sensitive data and restrict access accordingly. Param scrubbing is on the roadmap but not yet implemented. @@ -274,8 +325,8 @@ Note: CLI-based MCP clients (`claude mcp add`, Cursor, Copilot, etc.) store env `CRYSTALLIZE_ACCESS_MODE` controls which tools the MCP server registers at startup: - **`read`** (default) — read-only tools only -- **`write`** — includes tools that can create/update content (Phase 3) -- **`admin`** — full access including webhooks and tenant config (Phase 3) +- **`write`** — includes content creation and component updates (with dry-run support) +- **`admin`** — full access including shape modifications and tenant config ## Deep links diff --git a/src/tools/content.ts b/src/tools/content.ts index d19628a..4404fc0 100644 --- a/src/tools/content.ts +++ b/src/tools/content.ts @@ -38,7 +38,18 @@ function buildComponentInput( case 'singleLine': return { ...base, singleLine: { text: String(value) } }; case 'richText': - return { ...base, richText: { plainText: [String(value)] } }; + return { + ...base, + richText: { + json: [ + { + kind: 'block', + type: 'paragraph', + children: [{ kind: 'inline', textContent: String(value) }], + }, + ], + }, + }; case 'boolean': return { ...base, boolean: { value: Boolean(value) } }; case 'numeric': @@ -69,6 +80,110 @@ function buildComponentInput( } } +/** Convert PIM API component read content back to ComponentInput for round-trip mutations. */ +function contentToInput( + componentId: string, + componentType: string, + content: Record | null | undefined, +): ComponentInput | null { + const base = { componentId }; + if (!content) { + return null; + } + + switch (componentType) { + case 'singleLine': { + if (content.text == null) { + return null; + } + return { ...base, singleLine: { text: String(content.text) } }; + } + case 'richText': { + if (content.json != null) { + return { ...base, richText: { json: content.json } }; + } + if (content.plainText != null) { + const text = Array.isArray(content.plainText) + ? content.plainText.join('\n') + : String(content.plainText); + return { + ...base, + richText: { + json: [ + { + kind: 'block', + type: 'paragraph', + children: [{ kind: 'inline', textContent: text }], + }, + ], + }, + }; + } + return null; + } + case 'boolean': { + if (content.value == null) { + return null; + } + return { ...base, boolean: { value: Boolean(content.value) } }; + } + case 'numeric': { + if (content.number == null) { + return null; + } + const num: Record = { number: Number(content.number) }; + if (content.unit) { + num.unit = String(content.unit); + } + return { ...base, numeric: num }; + } + case 'selection': { + const opts = content.options; + if (!Array.isArray(opts) || opts.length === 0) { + return null; + } + return { + ...base, + selection: { + keys: opts.map((o: Record) => String(o.key)), + }, + }; + } + case 'images': { + const imgs = content.images; + if (!Array.isArray(imgs) || imgs.length === 0) { + return null; + } + return { + ...base, + images: imgs.map((img: Record) => { + const out: Record = { + key: String(img.key ?? ''), + }; + if (img.altText) { + out.altText = String(img.altText); + } + return out; + }), + }; + } + case 'itemRelations': { + const items = content.items; + if (!Array.isArray(items) || items.length === 0) { + return null; + } + return { + ...base, + itemRelations: { + itemIds: items.map((i: Record) => String(i.id)), + }, + }; + } + default: + return null; + } +} + export function contentTools(client: CrystallizeClient): ToolDefinition[] { return [ { @@ -252,7 +367,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { { name: 'update_component', description: - 'Update a single component on a catalogue item. Use get_item to see current values and get_shape to understand component types. Respects CRYSTALLIZE_DRY_RUN — when enabled, returns a before/after preview without changing anything.', + 'Update a single component on a catalogue item. Supports nested components inside content chunks using dot notation (e.g. "image-and-title.title"). Use get_item to see current values and get_shape to understand component types. Respects CRYSTALLIZE_DRY_RUN — when enabled, returns a before/after preview without changing anything.', mode: 'write', schema: { itemId: z @@ -263,15 +378,21 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { .string() .min(1) .describe( - 'Component ID to update — use get_shape to see available components', + 'Component ID to update. For nested components inside content chunks, use dot notation: "chunkId.childId" (e.g. "image-and-title.title")', ), value: z.unknown().describe('New value for the component'), language: z.string().default('en').describe('Language code'), }, handler: async params => { - const { itemId, componentId, value, language } = params; + const { itemId, value, language } = params; + const componentId = String(params.componentId); + + // Parse dot notation for nested components + const parts = componentId.split('.'); + const topLevelId = parts[0]; + const childId = parts.length > 1 ? parts.slice(1).join('.') : undefined; - // Fetch current item to get shape and current component value + // Fetch current item via PIM API for shape info (including chunk children) const itemQuery = ` query GetItemForUpdate($id: ID!, $language: String!) { item { @@ -279,16 +400,19 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { id name type - shape { identifier name components { id name type } } - components { - componentId - type - content { - ... on SingleLineContent { text } - ... on RichTextContent { plainText } - ... on BooleanContent { value } - ... on NumericContent { number unit } - ... on SelectionContent { options { key value } } + tree { path } + shape { + identifier + name + components { + id + name + type + config { + ... on ContentChunkComponentConfig { + components { id name type } + } + } } } } @@ -299,7 +423,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { const itemData = (await client.api.pimApi(itemQuery, { id: itemId, language, - })) as CoreItemResponse; + })) as ItemForUpdateResponse; const item = itemData.item?.get; if (!item) { @@ -315,8 +439,10 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { } // Find the component definition on the shape - const compDef = item.shape?.components?.find(c => c.id === componentId); - if (!compDef) { + const topLevelComp = item.shape?.components?.find( + c => c.id === topLevelId, + ); + if (!topLevelComp) { const available = (item.shape?.components ?? []) .map(c => c.id) .join(', '); @@ -324,30 +450,180 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { content: [ { type: 'text', - text: `Component "${componentId}" not found on shape "${item.shape?.identifier}". Available: ${available}`, + text: `Component "${topLevelId}" not found on shape "${item.shape?.identifier}". Available: ${available}`, }, ], isError: true, }; } - const currentComp = item.components?.find( - c => c.componentId === componentId, - ); - const currentValue = formatCurrentValue(currentComp); + // Resolve the target component type (may be nested in a chunk) + let targetType = topLevelComp.type; + let targetName = topLevelComp.name; + if (childId && topLevelComp.type === 'contentChunk') { + const chunkChildren = topLevelComp.config?.components ?? []; + const childComp = chunkChildren.find( + (c: ShapeComponent) => c.id === childId, + ); + if (!childComp) { + const available = chunkChildren + .map((c: ShapeComponent) => c.id) + .join(', '); + return { + content: [ + { + type: 'text', + text: `Child component "${childId}" not found in chunk "${topLevelId}". Available: ${available}`, + }, + ], + isError: true, + }; + } + targetType = childComp.type; + targetName = `${topLevelComp.name} → ${childComp.name}`; + } else if (childId) { + return { + content: [ + { + type: 'text', + text: `Dot notation "${componentId}" is only supported for contentChunk components. "${topLevelId}" is type "${topLevelComp.type}".`, + }, + ], + isError: true, + }; + } - const componentInput = buildComponentInput( - componentId, - compDef.type, + // Build the component input for the Core API + const innerInput = buildComponentInput( + childId ?? topLevelId, + targetType, value, ); + // Fetch current value and build mutation input + let currentValue = '(not available)'; + let componentInput: ComponentInput; + + if (childId) { + // For chunk children: fetch existing siblings via PIM API, + // extract previous value from the target child, and merge + let allChunkChildren: ComponentInput[] = [innerInput]; + try { + const chunkQuery = ` + query GetChunkContent($id: ID!, $language: String!) { + item { + get(id: $id, language: $language, versionLabel: draft) { + components { + componentId + type + content { + ... on ContentChunkContent { + chunks { + componentId + type + content { + ... on SingleLineContent { text } + ... on RichTextContent { json plainText } + ... on BooleanContent { value } + ... on NumericContent { number unit } + ... on SelectionContent { options { key value } } + ... on ImageContent { images { url key altText } } + ... on ItemRelationsContent { items { id } } + } + } + } + } + } + } + } + } + `; + const chunkData = (await client.api.pimApi(chunkQuery, { + id: itemId, + language, + })) as ChunkContentResponse; + const components = chunkData?.item?.get?.components ?? []; + const chunkComp = components.find( + (c: { componentId: string }) => c.componentId === topLevelId, + ); + const firstRow = chunkComp?.content?.chunks?.[0]; + if (firstRow) { + // Extract the current value from the target child + const targetChild = firstRow.find( + (c: ChunkChild) => c.componentId === childId, + ); + if (targetChild?.content) { + currentValue = formatContentValue(targetChild.content); + } + + let foundTarget = false; + const merged = firstRow + .map((c: ChunkChild) => { + if (c.componentId === childId) { + foundTarget = true; + return innerInput; + } + return contentToInput(c.componentId, c.type, c.content); + }) + .filter( + (v: ComponentInput | null): v is ComponentInput => v !== null, + ); + if (!foundTarget) { + merged.push(innerInput); + } + allChunkChildren = merged; + } + } catch { + // Non-critical — fall back to sending only the target child + } + + componentInput = { + componentId: topLevelId, + contentChunk: { + chunks: [allChunkChildren], + }, + }; + } else { + // For top-level components: fetch current value via Catalogue API + const itemPath = item.tree?.path; + if (itemPath) { + try { + const catQuery = ` + query GetComponentValue($path: String!, $language: String!) { + catalogue(path: $path, language: $language) { + component(id: "${topLevelId}") { + content { + ... on SingleLineContent { text } + ... on RichTextContent { plainText } + ... on BooleanContent { value } + ... on NumericContent { number unit } + ... on SelectionContent { options { key value } } + } + } + } + } + `; + const catData = (await client.api.catalogueApi(catQuery, { + path: itemPath, + language, + })) as CatalogueComponentResponse; + const content = catData.catalogue?.component?.content; + if (content) { + currentValue = formatContentValue(content); + } + } catch { + // Non-critical — proceed with update even if we can't read current value + } + } + componentInput = innerInput; + } + // Dry-run preview if (client.config.dryRun) { const lines = [ `Would update component on: "${item.name}" (${item.type})`, ` Item ID: ${itemId}`, - ` Component: ${compDef.name} (${componentId}, type: ${compDef.type})`, + ` Component: ${targetName} (${componentId}, type: ${targetType})`, ` Language: ${language}`, '', `Current value: ${currentValue}`, @@ -361,17 +637,16 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { } // Execute mutation via Core API (nextPimApi) + // updateComponent targets a single component without affecting others const mutation = ` mutation UpdateComponent($itemId: ID!, $language: String!, $component: ComponentInput!) { - item { - updateComponent( - itemId: $itemId - language: $language - component: $component - ) { - ... on UpdatedItem { itemId } - ... on BasicError { errorName message } - } + updateComponent( + itemId: $itemId + language: $language + component: $component + ) { + ... on UpdatedComponent { item { id } } + ... on BasicError { errorName message } } } `; @@ -382,7 +657,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { component: componentInput, })) as UpdateComponentResponse; - const updateResult = result.item?.updateComponent; + const updateResult = result.updateComponent; if (updateResult?.errorName) { return { content: [ @@ -400,8 +675,8 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { { type: 'text', text: [ - `Updated "${compDef.name}" on "${item.name}"`, - ` Component: ${componentId} (${compDef.type})`, + `Updated "${targetName}" on "${item.name}"`, + ` Component: ${componentId} (${targetType})`, ` Previous: ${currentValue}`, ` New: ${JSON.stringify(value)}`, ` Link: ${client.itemLink(itemId, item.type, language)}`, @@ -425,27 +700,45 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } -function formatCurrentValue(comp: CoreComponentContent | undefined): string { - const c = comp?.content; - if (!c) { - return '(empty)'; +function formatContentValue(content: Record): string { + if ('text' in content && content.text != null) { + return String(content.text); + } + if ('plainText' in content && content.plainText != null) { + return String(content.plainText); } - if (c.text != null) { - return String(c.text); + if ('value' in content && content.value != null) { + return String(content.value); } - if (c.plainText != null) { - return String(c.plainText); + if ('number' in content && content.number != null) { + const unit = 'unit' in content && content.unit ? ` ${content.unit}` : ''; + return `${content.number}${unit}`; } - if (c.value != null) { - return String(c.value); + if ('options' in content && Array.isArray(content.options)) { + return content.options + .map((o: Record) => o.value ?? o.key) + .join(', '); } - if (c.number != null) { - return c.unit ? `${c.number} ${c.unit}` : String(c.number); + if ('images' in content && Array.isArray(content.images)) { + const imgs = content.images as { key?: string; altText?: string }[]; + if (imgs.length === 0) { + return '(no images)'; + } + const label = imgs[0].altText || imgs[0].key || 'image'; + return imgs.length === 1 ? label : `${label} (+${imgs.length - 1} more)`; } - if (c.options) { - return c.options.map(o => o.value).join(', '); + if ('items' in content && Array.isArray(content.items)) { + const n = content.items.length; + return n === 0 ? '(no relations)' : `${n} related item${n > 1 ? 's' : ''}`; } - return '(unknown)'; + if ('files' in content && Array.isArray(content.files)) { + const n = content.files.length; + return n === 0 ? '(no files)' : `${n} file${n > 1 ? 's' : ''}`; + } + if ('json' in content) { + return '(rich text)'; + } + return '(complex value)'; } // --- Internal types --- @@ -454,6 +747,9 @@ interface ShapeComponent { id: string; name: string; type: string; + config?: { + components?: ShapeComponent[]; + }; } interface ShapeResponse { @@ -477,41 +773,54 @@ interface CreateItemResponse { }; } -interface CoreComponentContent { - componentId: string; - type: string; - content?: { - text?: string; - plainText?: string; - value?: boolean; - number?: number; - unit?: string; - options?: { key: string; value: string }[]; - }; -} - -interface CoreItemResponse { +interface ItemForUpdateResponse { item?: { get?: { id: string; name: string; type: string; + tree?: { path?: string }; shape?: { identifier: string; name: string; components?: ShapeComponent[]; }; - components?: CoreComponentContent[]; }; }; } -interface UpdateComponentResponse { +interface ChunkChild { + componentId: string; + type: string; + content?: Record | null; +} + +interface ChunkContentResponse { item?: { - updateComponent?: { - itemId?: string; - errorName?: string; - message?: string; + get?: { + components?: { + componentId: string; + type: string; + content?: { + chunks?: ChunkChild[][]; + }; + }[]; + }; + }; +} + +interface CatalogueComponentResponse { + catalogue?: { + component?: { + content?: Record; }; }; } + +interface UpdateComponentResponse { + updateComponent?: { + item?: { id: string }; + errorName?: string; + message?: string; + }; +} diff --git a/tests/content.test.ts b/tests/content.test.ts index 09f7909..7177951 100644 --- a/tests/content.test.ts +++ b/tests/content.test.ts @@ -173,6 +173,7 @@ describe('update_component dry-run', () => { dryRun: true, }); + // PIM API returns item shape info + path Object.defineProperty(client.api, 'pimApi', { value: async () => ({ item: { @@ -180,6 +181,7 @@ describe('update_component dry-run', () => { id: 'item-123', name: 'My Product', type: 'product', + tree: { path: '/products/my-product' }, shape: { identifier: 'product-shape', name: 'Product', @@ -187,13 +189,19 @@ describe('update_component dry-run', () => { { id: 'description', name: 'Description', type: 'singleLine' }, ], }, - components: [ - { - componentId: 'description', - type: 'singleLine', - content: { text: 'Old description' }, - }, - ], + }, + }, + }), + writable: true, + configurable: true, + }); + + // Catalogue API returns current component value + Object.defineProperty(client.api, 'catalogueApi', { + value: async () => ({ + catalogue: { + component: { + content: { text: 'Old description' }, }, }, }), @@ -266,12 +274,12 @@ describe('update_component dry-run', () => { id: 'item-123', name: 'Test Item', type: 'document', + tree: { path: '/test' }, shape: { identifier: 'doc-shape', name: 'Doc', components: [{ id: 'title', name: 'Title', type: 'singleLine' }], }, - components: [], }, }, }), @@ -296,6 +304,130 @@ describe('update_component dry-run', () => { assert.ok(result.content[0].text.includes('nonexistent')); assert.ok(result.content[0].text.includes('title')); }); + + it('supports dot notation for contentChunk children', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'write', + dryRun: true, + }); + + Object.defineProperty(client.api, 'pimApi', { + value: async () => ({ + item: { + get: { + id: 'item-456', + name: 'Article', + type: 'document', + tree: { path: '/articles/test' }, + shape: { + identifier: 'article', + name: 'Article', + components: [ + { + id: 'hero', + name: 'Hero', + type: 'contentChunk', + config: { + components: [ + { id: 'title', name: 'Title', type: 'singleLine' }, + { id: 'image', name: 'Image', type: 'images' }, + ], + }, + }, + ], + }, + }, + }, + }), + writable: true, + configurable: true, + }); + + Object.defineProperty(client.api, 'catalogueApi', { + value: async () => ({ + catalogue: { component: { content: null } }, + }), + writable: true, + configurable: true, + }); + + const tools = contentTools(client); + const updateComp = tools.find(t => t.name === 'update_component'); + if (!updateComp) { + throw new Error('update_component not found'); + } + + const result = await updateComp.handler({ + itemId: 'item-456', + componentId: 'hero.title', + value: 'New Hero Title', + language: 'en', + }); + + assert.strictEqual(result.isError, undefined); + const text = result.content[0].text; + assert.ok(text.includes('[DRY RUN]')); + assert.ok(text.includes('Hero')); + assert.ok(text.includes('New Hero Title')); + assert.ok(text.includes('contentChunk')); + }); + + it('returns error for invalid chunk child', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'write', + dryRun: true, + }); + + Object.defineProperty(client.api, 'pimApi', { + value: async () => ({ + item: { + get: { + id: 'item-456', + name: 'Article', + type: 'document', + tree: { path: '/articles/test' }, + shape: { + identifier: 'article', + name: 'Article', + components: [ + { + id: 'hero', + name: 'Hero', + type: 'contentChunk', + config: { + components: [ + { id: 'title', name: 'Title', type: 'singleLine' }, + ], + }, + }, + ], + }, + }, + }, + }), + writable: true, + configurable: true, + }); + + const tools = contentTools(client); + const updateComp = tools.find(t => t.name === 'update_component'); + if (!updateComp) { + throw new Error('update_component not found'); + } + + const result = await updateComp.handler({ + itemId: 'item-456', + componentId: 'hero.nonexistent', + value: 'test', + language: 'en', + }); + + assert.strictEqual(result.isError, true); + assert.ok(result.content[0].text.includes('nonexistent')); + assert.ok(result.content[0].text.includes('title')); + }); }); // --- Audit mutation metadata --- From 95b56ea061f326fa8c52f393eac2159e4f0406dc Mon Sep 17 00:00:00 2001 From: haz Date: Fri, 10 Apr 2026 09:29:52 +0200 Subject: [PATCH 2/3] fix(PR): - Unsupp sibling safety --- src/tools/content.ts | 45 +++++++++++++++++++++++++++++++++---------- tests/content.test.ts | 28 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/tools/content.ts b/src/tools/content.ts index 4404fc0..6def379 100644 --- a/src/tools/content.ts +++ b/src/tools/content.ts @@ -389,6 +389,17 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { // Parse dot notation for nested components const parts = componentId.split('.'); + if (parts.some(p => p.length === 0)) { + return { + content: [ + { + type: 'text', + text: 'Invalid componentId — dot notation must not contain empty segments (e.g. ".title", "hero.", "hero..title"). Use "componentId" or "chunkId.childId".', + }, + ], + isError: true, + }; + } const topLevelId = parts[0]; const childId = parts.length > 1 ? parts.slice(1).join('.') : undefined; @@ -557,17 +568,31 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { } let foundTarget = false; - const merged = firstRow - .map((c: ChunkChild) => { - if (c.componentId === childId) { - foundTarget = true; - return innerInput; - } - return contentToInput(c.componentId, c.type, c.content); - }) - .filter( - (v: ComponentInput | null): v is ComponentInput => v !== null, + const merged: ComponentInput[] = []; + for (const c of firstRow) { + if (c.componentId === childId) { + foundTarget = true; + merged.push(innerInput); + continue; + } + const siblingInput = contentToInput( + c.componentId, + c.type, + c.content, ); + if (siblingInput === null) { + return { + content: [ + { + type: 'text', + text: `Cannot update "${childId}" in chunk "${topLevelId}": sibling "${c.componentId}" has unsupported type "${c.type}". Updating would wipe that sibling's data.`, + }, + ], + isError: true, + }; + } + merged.push(siblingInput); + } if (!foundTarget) { merged.push(innerInput); } diff --git a/tests/content.test.ts b/tests/content.test.ts index 7177951..0f37528 100644 --- a/tests/content.test.ts +++ b/tests/content.test.ts @@ -428,6 +428,34 @@ describe('update_component dry-run', () => { assert.ok(result.content[0].text.includes('nonexistent')); assert.ok(result.content[0].text.includes('title')); }); + + it('returns error for empty dot notation segments', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'write', + dryRun: true, + }); + + const tools = contentTools(client); + const updateComp = tools.find(t => t.name === 'update_component'); + if (!updateComp) { + throw new Error('update_component not found'); + } + + for (const bad of ['.title', 'hero.', 'hero..title']) { + const result = await updateComp.handler({ + itemId: 'item-123', + componentId: bad, + value: 'test', + language: 'en', + }); + assert.strictEqual(result.isError, true, `Expected error for "${bad}"`); + assert.ok( + result.content[0].text.includes('empty segments'), + `Expected empty segments error for "${bad}"`, + ); + } + }); }); // --- Audit mutation metadata --- From 89efe5de3ef0486f632bf9082d873755c5314499 Mon Sep 17 00:00:00 2001 From: haz Date: Fri, 10 Apr 2026 10:56:24 +0200 Subject: [PATCH 3/3] fix: repair get_shape, get_item, and update_component for content chunks - get_shape: fix GraphQL inline fragments (repeatable vs repeating, proper type spreads) - get_item: add ContentChunkContent fragment so chunk rows are returned - update_component: add rowIndex param to target specific rows in repeating chunks; fix critical bug where only the target row was sent back in the mutation, wiping all other rows (shadowed const allRows inside try block) --- src/tools/catalogue.ts | 33 +++++++++ src/tools/content.ts | 158 ++++++++++++++++++++++++++++------------- src/tools/shapes.ts | 47 +++++++++--- 3 files changed, 180 insertions(+), 58 deletions(-) diff --git a/src/tools/catalogue.ts b/src/tools/catalogue.ts index b70a4c6..52845a6 100644 --- a/src/tools/catalogue.ts +++ b/src/tools/catalogue.ts @@ -98,6 +98,21 @@ export function catalogueTools(client: CrystallizeClient): ToolDefinition[] { ... on ComponentChoiceContent { selectedComponent { id name type } } + ... on ContentChunkContent { + chunks { + id + name + type + content { + ... on SingleLineContent { text } + ... on RichTextContent { plainText } + ... on BooleanContent { value } + ... on NumericContent { number unit } + ... on SelectionContent { options { key value } } + ... on ImageContent { images { url altText } } + } + } + } } } } @@ -424,6 +439,24 @@ function formatComponentContent( const options = content.options as { key: string; value: string }[]; return options.map(o => o.value).join(', '); } + if ('chunks' in content) { + const chunks = content.chunks as Array< + Array<{ + id: string; + name: string; + type: string; + content: Record | null; + }> + >; + const rows = chunks.map((row, i) => { + const first = row[0]; + const firstVal = first + ? formatComponentContent(first.content) + : '(empty)'; + return `row ${i + 1}: [${first?.id ?? '?'}] ${firstVal}`; + }); + return `${chunks.length} row(s) — ${rows.join(' | ')}`; + } return JSON.stringify(content); } diff --git a/src/tools/content.ts b/src/tools/content.ts index 6def379..22746f2 100644 --- a/src/tools/content.ts +++ b/src/tools/content.ts @@ -87,16 +87,27 @@ function contentToInput( content: Record | null | undefined, ): ComponentInput | null { const base = { componentId }; + + // Empty content — preserve as empty component rather than dropping if (!content) { - return null; + switch (componentType) { + case 'singleLine': + return { ...base, singleLine: { text: '' } }; + case 'richText': + return null; + case 'boolean': + return null; + default: + return null; + } } switch (componentType) { case 'singleLine': { - if (content.text == null) { - return null; - } - return { ...base, singleLine: { text: String(content.text) } }; + return { + ...base, + singleLine: { text: content.text != null ? String(content.text) : '' }, + }; } case 'richText': { if (content.json != null) { @@ -367,7 +378,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { { name: 'update_component', description: - 'Update a single component on a catalogue item. Supports nested components inside content chunks using dot notation (e.g. "image-and-title.title"). Use get_item to see current values and get_shape to understand component types. Respects CRYSTALLIZE_DRY_RUN — when enabled, returns a before/after preview without changing anything.', + 'Update a single component on a catalogue item. Supports nested components inside content chunks using dot notation (e.g. "image-and-title.title"). For repeating content chunks, use rowIndex to target a specific row (0 = first row). Use get_item to see current values and get_shape to understand component types. Respects CRYSTALLIZE_DRY_RUN — when enabled, returns a before/after preview without changing anything.', mode: 'write', schema: { itemId: z @@ -380,12 +391,22 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { .describe( 'Component ID to update. For nested components inside content chunks, use dot notation: "chunkId.childId" (e.g. "image-and-title.title")', ), + rowIndex: z + .number() + .int() + .min(0) + .default(0) + .describe( + 'For repeating content chunks: 0-based row index to update (default: 0 = first row)', + ), value: z.unknown().describe('New value for the component'), language: z.string().default('en').describe('Language code'), }, handler: async params => { const { itemId, value, language } = params; const componentId = String(params.componentId); + const rowIndex = + typeof params.rowIndex === 'number' ? params.rowIndex : 0; // Parse dot notation for nested components const parts = componentId.split('.'); @@ -518,7 +539,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { if (childId) { // For chunk children: fetch existing siblings via PIM API, // extract previous value from the target child, and merge - let allChunkChildren: ComponentInput[] = [innerInput]; + let allRows: ComponentInput[][] = [[innerInput]]; try { const chunkQuery = ` query GetChunkContent($id: ID!, $language: String!) { @@ -557,55 +578,93 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { const chunkComp = components.find( (c: { componentId: string }) => c.componentId === topLevelId, ); - const firstRow = chunkComp?.content?.chunks?.[0]; - if (firstRow) { - // Extract the current value from the target child - const targetChild = firstRow.find( - (c: ChunkChild) => c.componentId === childId, + const allChunkRows: ChunkChild[][] = + chunkComp?.content?.chunks ?? []; + + if (rowIndex >= allChunkRows.length && allChunkRows.length > 0) { + return { + content: [ + { + type: 'text', + text: `Row index ${rowIndex} is out of bounds — chunk "${topLevelId}" has ${allChunkRows.length} row(s) (0-based). Use get_item to inspect the chunk.`, + }, + ], + isError: true, + }; + } + + const targetRow = allChunkRows[rowIndex] ?? []; + + // Extract current value from the target child in the target row + const targetChild = targetRow.find( + (c: ChunkChild) => c.componentId === childId, + ); + if (targetChild?.content) { + currentValue = formatContentValue(targetChild.content); + } + + // Merge siblings in the target row, replacing only the target child + let foundTarget = false; + const mergedRow: ComponentInput[] = []; + for (const c of targetRow) { + if (c.componentId === childId) { + foundTarget = true; + mergedRow.push(innerInput); + continue; + } + const siblingInput = contentToInput( + c.componentId, + c.type, + c.content, ); - if (targetChild?.content) { - currentValue = formatContentValue(targetChild.content); + if (siblingInput === null) { + return { + content: [ + { + type: 'text', + text: `Cannot update "${childId}" in chunk "${topLevelId}" row ${rowIndex}: sibling "${c.componentId}" has unsupported type "${c.type}". Updating would wipe that sibling's data.`, + }, + ], + isError: true, + }; } + mergedRow.push(siblingInput); + } + if (!foundTarget) { + mergedRow.push(innerInput); + } - let foundTarget = false; - const merged: ComponentInput[] = []; - for (const c of firstRow) { - if (c.componentId === childId) { - foundTarget = true; - merged.push(innerInput); - continue; - } - const siblingInput = contentToInput( - c.componentId, - c.type, - c.content, - ); - if (siblingInput === null) { - return { - content: [ - { - type: 'text', - text: `Cannot update "${childId}" in chunk "${topLevelId}": sibling "${c.componentId}" has unsupported type "${c.type}". Updating would wipe that sibling's data.`, - }, - ], - isError: true, - }; - } - merged.push(siblingInput); - } - if (!foundTarget) { - merged.push(innerInput); + // Rebuild all rows, replacing only the target row + allRows = allChunkRows.map((row, i) => { + if (i === rowIndex) { + return mergedRow; } - allChunkChildren = merged; + return row + .map((c: ChunkChild) => + contentToInput(c.componentId, c.type, c.content), + ) + .filter((x): x is ComponentInput => x !== null); + }); + // If rowIndex is beyond existing rows, append + if (rowIndex >= allChunkRows.length) { + allRows.push(mergedRow); } } catch { - // Non-critical — fall back to sending only the target child + return { + content: [ + { + type: 'text', + text: `Failed to fetch existing chunk data for "${topLevelId}". Cannot safely update "${childId}" without knowing sibling values — proceeding would risk wiping sibling components.`, + }, + ], + isError: true, + }; } componentInput = { componentId: topLevelId, contentChunk: { - chunks: [allChunkChildren], + chunks: allRows, }, }; } else { @@ -614,9 +673,9 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { if (itemPath) { try { const catQuery = ` - query GetComponentValue($path: String!, $language: String!) { + query GetComponentValue($path: String!, $language: String!, $componentId: String!) { catalogue(path: $path, language: $language) { - component(id: "${topLevelId}") { + component(id: $componentId) { content { ... on SingleLineContent { text } ... on RichTextContent { plainText } @@ -631,6 +690,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { const catData = (await client.api.catalogueApi(catQuery, { path: itemPath, language, + componentId: topLevelId, })) as CatalogueComponentResponse; const content = catData.catalogue?.component?.content; if (content) { @@ -648,7 +708,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { const lines = [ `Would update component on: "${item.name}" (${item.type})`, ` Item ID: ${itemId}`, - ` Component: ${targetName} (${componentId}, type: ${targetType})`, + ` Component: ${targetName} (${componentId}, type: ${targetType})${childId ? `, row: ${rowIndex}` : ''}`, ` Language: ${language}`, '', `Current value: ${currentValue}`, @@ -701,7 +761,7 @@ export function contentTools(client: CrystallizeClient): ToolDefinition[] { type: 'text', text: [ `Updated "${targetName}" on "${item.name}"`, - ` Component: ${componentId} (${targetType})`, + ` Component: ${componentId} (${targetType})${childId ? `, row: ${rowIndex}` : ''}`, ` Previous: ${currentValue}`, ` New: ${JSON.stringify(value)}`, ` Link: ${client.itemLink(itemId, item.type, language)}`, diff --git a/src/tools/shapes.ts b/src/tools/shapes.ts index 852044c..ec2af75 100644 --- a/src/tools/shapes.ts +++ b/src/tools/shapes.ts @@ -93,7 +93,22 @@ export function shapeTools(client: CrystallizeClient): ToolDefinition[] { type description config { - ... on ComponentConfig { + ... on ContentChunkComponentConfig { + repeatable + } + ... on SelectionComponentConfig { + min + max + } + ... on ItemRelationsComponentConfig { + min + max + } + ... on FilesComponentConfig { + min + max + } + ... on SingleLineComponentConfig { min max } @@ -104,6 +119,11 @@ export function shapeTools(client: CrystallizeClient): ToolDefinition[] { name type description + config { + ... on ContentChunkComponentConfig { + repeatable + } + } } } } @@ -135,13 +155,25 @@ export function shapeTools(client: CrystallizeClient): ToolDefinition[] { '', ]; + const formatComponent = (comp: ComponentDetail, indent = ' ') => { + const parts: string[] = []; + let label = `${indent}${comp.id} — ${comp.name} [${comp.type}]`; + if (comp.type === 'contentChunk' && comp.config) { + const repeatable = (comp.config as { repeatable?: boolean }) + .repeatable; + label += repeatable ? ' (repeatable: YES)' : ' (repeatable: no)'; + } + parts.push(label); + if (comp.description) { + parts.push(`${indent} ${comp.description}`); + } + return parts; + }; + if (shape.components?.length) { lines.push(`Item Components (${shape.components.length}):`); for (const comp of shape.components) { - lines.push(` ${comp.id} — ${comp.name} [${comp.type}]`); - if (comp.description) { - lines.push(` ${comp.description}`); - } + lines.push(...formatComponent(comp)); } lines.push(''); } @@ -149,10 +181,7 @@ export function shapeTools(client: CrystallizeClient): ToolDefinition[] { if (shape.variantComponents?.length) { lines.push(`Variant Components (${shape.variantComponents.length}):`); for (const comp of shape.variantComponents) { - lines.push(` ${comp.id} — ${comp.name} [${comp.type}]`); - if (comp.description) { - lines.push(` ${comp.description}`); - } + lines.push(...formatComponent(comp)); } }