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/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 d19628a..22746f2 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,121 @@ 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 }; + + // Empty content — preserve as empty component rather than dropping + if (!content) { + switch (componentType) { + case 'singleLine': + return { ...base, singleLine: { text: '' } }; + case 'richText': + return null; + case 'boolean': + return null; + default: + return null; + } + } + + switch (componentType) { + case 'singleLine': { + return { + ...base, + singleLine: { text: content.text != null ? 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 +378,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"). 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 @@ -263,15 +389,42 @@ 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")', + ), + 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, componentId, value, language } = 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('.'); + 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; - // 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 +432,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 +455,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 +471,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 +482,233 @@ 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 allRows: 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 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 (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); + } + + // Rebuild all rows, replacing only the target row + allRows = allChunkRows.map((row, i) => { + if (i === rowIndex) { + return mergedRow; + } + 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 { + 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: allRows, + }, + }; + } 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!, $componentId: String!) { + catalogue(path: $path, language: $language) { + component(id: $componentId) { + 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, + componentId: topLevelId, + })) 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})${childId ? `, row: ${rowIndex}` : ''}`, ` Language: ${language}`, '', `Current value: ${currentValue}`, @@ -361,17 +722,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 +742,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 +760,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})${childId ? `, row: ${rowIndex}` : ''}`, ` Previous: ${currentValue}`, ` New: ${JSON.stringify(value)}`, ` Link: ${client.itemLink(itemId, item.type, language)}`, @@ -425,27 +785,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 +832,9 @@ interface ShapeComponent { id: string; name: string; type: string; + config?: { + components?: ShapeComponent[]; + }; } interface ShapeResponse { @@ -477,41 +858,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/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)); } } diff --git a/tests/content.test.ts b/tests/content.test.ts index 09f7909..0f37528 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,158 @@ 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')); + }); + + 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 ---