diff --git a/packages/mcp-tools/src/tools/exo/components/createComponent.test.ts b/packages/mcp-tools/src/tools/exo/components/createComponent.test.ts index a088613c..fc00b907 100644 --- a/packages/mcp-tools/src/tools/exo/components/createComponent.test.ts +++ b/packages/mcp-tools/src/tools/exo/components/createComponent.test.ts @@ -89,6 +89,17 @@ describe('createComponent', () => { }); }); + it('omits viewports when creating a viewport-free component', async () => { + const viewportFreeArgs = { ...args, viewports: undefined }; + mockComponentCreate.mockResolvedValue(mockComponent); + + await createComponentTool(mockConfig)(viewportFreeArgs); + + expect(mockComponentCreate.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects writes to a protected environment', async () => { const protectedConfig = createMockConfig({ protectedEnvironments: ['test-environment'], diff --git a/packages/mcp-tools/src/tools/exo/components/createComponent.ts b/packages/mcp-tools/src/tools/exo/components/createComponent.ts index 81d77eb1..3aee6475 100644 --- a/packages/mcp-tools/src/tools/exo/components/createComponent.ts +++ b/packages/mcp-tools/src/tools/exo/components/createComponent.ts @@ -16,6 +16,10 @@ import { TreeNodeSchema, ExoMetadataSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayload, + type ViewportOptionalPayload, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const CreateComponentToolParams = BaseToolSchema.extend({ @@ -29,7 +33,10 @@ export const CreateComponentToolParams = BaseToolSchema.extend({ description: z.string().describe('Description of the component'), viewports: z .array(ViewportSchema) - .describe('Viewport definitions for the component (may be empty)'), + .optional() + .describe( + 'Optional viewport definitions for the component. Omit for viewport-free components.', + ), contentProperties: z .array(ContentPropertySchema) .describe('Content property definitions (may be empty)'), @@ -63,13 +70,15 @@ export function createComponentTool(config: ContentfulConfig) { const componentData = { name: args.name, description: args.description, - viewports: args.viewports, + ...(args.viewports !== undefined && { viewports: args.viewports }), contentProperties: args.contentProperties, designProperties: args.designProperties, ...(args.componentTree && { componentTree: args.componentTree }), ...(args.slots && { slots: args.slots }), ...(args.metadata && { metadata: args.metadata }), - }; + } satisfies ViewportOptionalPayload< + Parameters[1] + >; // Create the component with or without an explicit ID. Providing an ID // uses upsert (PUT) with no sys.version, which the CMA treats as a create. @@ -80,14 +89,16 @@ export function createComponentTool(config: ContentfulConfig) { environmentId: args.environmentId, componentId: args.componentId, }, - { + asViewportOptionalCmaPayload< + Parameters[1] + >({ sys: { id: args.componentId, type: 'Component' }, ...componentData, - }, + }), ) : await contentfulClient.component.create( { spaceId: args.spaceId, environmentId: args.environmentId }, - componentData, + asViewportOptionalCmaPayload(componentData), ); return createSuccessResponse('Component created successfully', { diff --git a/packages/mcp-tools/src/tools/exo/components/upsertComponent.test.ts b/packages/mcp-tools/src/tools/exo/components/upsertComponent.test.ts index d4c7e480..7a267cbc 100644 --- a/packages/mcp-tools/src/tools/exo/components/upsertComponent.test.ts +++ b/packages/mcp-tools/src/tools/exo/components/upsertComponent.test.ts @@ -79,6 +79,20 @@ describe('upsertComponent', () => { expect(body.dataAssemblies).toEqual(dataAssemblies); }); + it('does not reintroduce viewports from a viewport-free component', async () => { + mockComponentGet.mockResolvedValue({ + ...mockComponent, + viewports: undefined, + }); + mockComponentUpsert.mockResolvedValue(mockComponent); + + await upsertComponentTool(mockConfig)({ ...mockArgs, version: 1 }); + + expect(mockComponentUpsert.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects a stale version', async () => { mockComponentGet.mockResolvedValue(mockComponent); // sys.version === 1 diff --git a/packages/mcp-tools/src/tools/exo/components/upsertComponent.ts b/packages/mcp-tools/src/tools/exo/components/upsertComponent.ts index 475eb196..a828dcef 100644 --- a/packages/mcp-tools/src/tools/exo/components/upsertComponent.ts +++ b/packages/mcp-tools/src/tools/exo/components/upsertComponent.ts @@ -16,6 +16,10 @@ import { TreeNodeSchema, ExoMetadataSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayload, + type ViewportOptionalPayload, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const UpsertComponentToolParams = BaseToolSchema.extend({ @@ -86,7 +90,9 @@ export function upsertComponentTool(config: ContentfulConfig) { ); } - const component = await contentfulClient.component.upsert(params, { + const viewports = args.viewports ?? current.viewports; + + const componentData = { sys: { id: current.sys.id, type: 'Component', @@ -94,7 +100,7 @@ export function upsertComponentTool(config: ContentfulConfig) { }, name: args.name ?? current.name, description: args.description ?? current.description, - viewports: args.viewports ?? current.viewports, + ...(viewports !== undefined && { viewports }), contentProperties: args.contentProperties ?? current.contentProperties, designProperties: args.designProperties ?? current.designProperties, ...((args.componentTree ?? current.componentTree) @@ -109,7 +115,14 @@ export function upsertComponentTool(config: ContentfulConfig) { ...(current.dataAssemblies ? { dataAssemblies: current.dataAssemblies } : {}), - }); + } satisfies ViewportOptionalPayload< + Parameters[1] + >; + + const component = await contentfulClient.component.upsert( + params, + asViewportOptionalCmaPayload(componentData), + ); return createSuccessResponse('Component updated successfully', { component, diff --git a/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.test.ts b/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.test.ts index 2925e27d..95aa62e0 100644 --- a/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.test.ts +++ b/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.test.ts @@ -47,6 +47,27 @@ describe('createExperienceFragment', () => { ); }); + it('creates a viewport-free experience fragment with flattened design properties', async () => { + const viewportFreeArgs = { ...baseArgs, viewports: undefined }; + mockExperienceFragmentCreate.mockResolvedValue(mockExperienceFragment); + + await createExperienceFragmentTool(mockConfig)({ + ...viewportFreeArgs, + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + + expect(mockExperienceFragmentCreate.mock.calls[0][1]).toMatchObject({ + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + expect(mockExperienceFragmentCreate.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects creates in a protected environment', async () => { const tool = createExperienceFragmentTool( createMockConfig({ protectedEnvironments: ['test-environment'] }), diff --git a/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.ts b/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.ts index 15cb87ac..00496a0a 100644 --- a/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.ts +++ b/packages/mcp-tools/src/tools/exo/experience-fragments/createExperienceFragment.ts @@ -11,11 +11,16 @@ import { import { ViewportSchema, ExperienceMetadataSchema, + DesignPropertyValueSchema, DimensionedDesignPropertyValueSchema, ExperienceContentBindingsSchema, ExperienceSlotNodeSchema, ComponentResourceLinkSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayloadWithFlattenedDesignProperties, + type ViewportOptionalPayloadWithFlattenedDesignProperties, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const CreateExperienceFragmentToolParams = BaseToolSchema.extend({ @@ -26,9 +31,18 @@ export const CreateExperienceFragmentToolParams = BaseToolSchema.extend({ ), viewports: z .array(ViewportSchema) - .describe('Viewport definitions (may be empty)'), + .optional() + .describe( + 'Optional viewport definitions for the experience fragment. Omit for viewport-free experience fragments.', + ), designProperties: z - .record(z.string(), DimensionedDesignPropertyValueSchema) + .record( + z.string(), + z.union([ + DesignPropertyValueSchema, + DimensionedDesignPropertyValueSchema, + ]), + ) .describe( 'Design property values keyed by property ID (may be empty object)', ), @@ -55,18 +69,24 @@ export function createExperienceFragmentTool(config: ContentfulConfig) { const contentfulClient = createExoToolClient(config, args); + const experienceFragmentData = { + name: args.name, + description: args.description, + component: args.component, + ...(args.viewports !== undefined && { viewports: args.viewports }), + designProperties: args.designProperties, + ...(args.contentBindings && { contentBindings: args.contentBindings }), + ...(args.slots && { slots: args.slots }), + ...(args.metadata && { metadata: args.metadata }), + } satisfies ViewportOptionalPayloadWithFlattenedDesignProperties< + Parameters[1] + >; + const experienceFragment = await contentfulClient.experienceFragment.create( { spaceId: args.spaceId, environmentId: args.environmentId }, - { - name: args.name, - description: args.description, - component: args.component, - viewports: args.viewports, - designProperties: args.designProperties, - ...(args.contentBindings && { contentBindings: args.contentBindings }), - ...(args.slots && { slots: args.slots }), - ...(args.metadata && { metadata: args.metadata }), - }, + asViewportOptionalCmaPayloadWithFlattenedDesignProperties( + experienceFragmentData, + ), ); return createSuccessResponse('Experience fragment created successfully', { diff --git a/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.test.ts b/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.test.ts index 65c5d1e2..22c2a10b 100644 --- a/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.test.ts +++ b/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.test.ts @@ -48,6 +48,25 @@ describe('updateExperienceFragment', () => { ); }); + it('preserves flattened design properties without reintroducing viewports', async () => { + mockExperienceFragmentGet.mockResolvedValue({ + ...mockExperienceFragment, + viewports: undefined, + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + mockExperienceFragmentUpsert.mockResolvedValue(mockExperienceFragment); + + await updateExperienceFragmentTool(mockConfig)({ ...mockArgs, version: 1 }); + + const [, body] = mockExperienceFragmentUpsert.mock.calls[0]; + expect(body).not.toHaveProperty('viewports'); + expect(body.designProperties).toEqual({ + color: { type: 'ManualDesignValue', value: 'red' }, + }); + }); + it('rejects a stale version', async () => { mockExperienceFragmentGet.mockResolvedValue(mockExperienceFragment); // sys.version === 1 diff --git a/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.ts b/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.ts index 42ea62a2..1df851a9 100644 --- a/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.ts +++ b/packages/mcp-tools/src/tools/exo/experience-fragments/updateExperienceFragment.ts @@ -11,10 +11,15 @@ import { import { ViewportSchema, ExperienceMetadataSchema, + DesignPropertyValueSchema, DimensionedDesignPropertyValueSchema, ExperienceContentBindingsSchema, ExperienceSlotNodeSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayloadWithFlattenedDesignProperties, + type ViewportOptionalPayloadWithFlattenedDesignProperties, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const UpdateExperienceFragmentToolParams = BaseToolSchema.extend({ @@ -39,7 +44,13 @@ export const UpdateExperienceFragmentToolParams = BaseToolSchema.extend({ .optional() .describe('Viewport definitions; replaces existing viewports if provided'), designProperties: z - .record(z.string(), DimensionedDesignPropertyValueSchema) + .record( + z.string(), + z.union([ + DesignPropertyValueSchema, + DimensionedDesignPropertyValueSchema, + ]), + ) .optional() .describe('Design property values; replaces existing if provided'), contentBindings: ExperienceContentBindingsSchema.optional().describe( @@ -87,28 +98,36 @@ export function updateExperienceFragmentTool(config: ContentfulConfig) { // call is upsert(): the read-before-write guard above means this only ever updates // an existing fragment, and `update` is the verb the tool surface exposes for that. // Do not "fix" the tool name to match the SDK method. + const viewports = args.viewports ?? current.viewports; + + const experienceFragmentData = { + sys: { + id: current.sys.id, + type: 'ExperienceFragment', + version: current.sys.version, + }, + name: args.name ?? current.name, + description: args.description ?? current.description, + ...(viewports !== undefined && { viewports }), + designProperties: args.designProperties ?? current.designProperties, + ...((args.contentBindings ?? current.contentBindings) + ? { contentBindings: args.contentBindings ?? current.contentBindings } + : {}), + ...((args.slots ?? current.slots) + ? { slots: args.slots ?? current.slots } + : {}), + ...((args.metadata ?? current.metadata) + ? { metadata: args.metadata ?? current.metadata } + : {}), + } satisfies ViewportOptionalPayloadWithFlattenedDesignProperties< + Parameters[1] + >; + const experienceFragment = await contentfulClient.experienceFragment.upsert( params, - { - sys: { - id: current.sys.id, - type: 'ExperienceFragment', - version: current.sys.version, - }, - name: args.name ?? current.name, - description: args.description ?? current.description, - viewports: args.viewports ?? current.viewports, - designProperties: args.designProperties ?? current.designProperties, - ...((args.contentBindings ?? current.contentBindings) - ? { contentBindings: args.contentBindings ?? current.contentBindings } - : {}), - ...((args.slots ?? current.slots) - ? { slots: args.slots ?? current.slots } - : {}), - ...((args.metadata ?? current.metadata) - ? { metadata: args.metadata ?? current.metadata } - : {}), - }, + asViewportOptionalCmaPayloadWithFlattenedDesignProperties( + experienceFragmentData, + ), ); return createSuccessResponse('Experience fragment updated successfully', { diff --git a/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.test.ts b/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.test.ts index ae0693e0..f306b631 100644 --- a/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.test.ts +++ b/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.test.ts @@ -35,6 +35,17 @@ describe('createExperienceTemplate', () => { ); }); + it('omits viewports when creating a viewport-free experience template', async () => { + const viewportFreeArgs = { ...createArgs, viewports: undefined }; + mockExperienceTemplateCreate.mockResolvedValue(mockExperienceTemplate); + + await createExperienceTemplateTool(mockConfig)(viewportFreeArgs); + + expect(mockExperienceTemplateCreate.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects writes to a protected environment', async () => { const tool = createExperienceTemplateTool( createMockConfig({ protectedEnvironments: ['test-environment'] }), diff --git a/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.ts b/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.ts index 107e798f..5af867d1 100644 --- a/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.ts +++ b/packages/mcp-tools/src/tools/exo/experience-templates/createExperienceTemplate.ts @@ -16,6 +16,10 @@ import { TreeNodeSchema, ExoMetadataSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayload, + type ViewportOptionalPayload, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const CreateExperienceTemplateToolParams = BaseToolSchema.extend({ @@ -23,8 +27,9 @@ export const CreateExperienceTemplateToolParams = BaseToolSchema.extend({ description: z.string().describe('Description of the experience template'), viewports: z .array(ViewportSchema) + .optional() .describe( - 'Viewport definitions for the experience template (may be empty)', + 'Optional viewport definitions for the experience template. Omit for viewport-free experience templates.', ), contentProperties: z .array(ContentPropertySchema) @@ -56,18 +61,22 @@ export function createExperienceTemplateTool(config: ContentfulConfig) { const contentfulClient = createExoToolClient(config, args); + const experienceTemplateData = { + name: args.name, + description: args.description, + ...(args.viewports !== undefined && { viewports: args.viewports }), + contentProperties: args.contentProperties, + designProperties: args.designProperties, + ...(args.componentTree && { componentTree: args.componentTree }), + ...(args.slots && { slots: args.slots }), + ...(args.metadata && { metadata: args.metadata }), + } satisfies ViewportOptionalPayload< + Parameters[1] + >; + const experienceTemplate = await contentfulClient.experienceTemplate.create( { spaceId: args.spaceId, environmentId: args.environmentId }, - { - name: args.name, - description: args.description, - viewports: args.viewports, - contentProperties: args.contentProperties, - designProperties: args.designProperties, - ...(args.componentTree && { componentTree: args.componentTree }), - ...(args.slots && { slots: args.slots }), - ...(args.metadata && { metadata: args.metadata }), - }, + asViewportOptionalCmaPayload(experienceTemplateData), ); return createSuccessResponse('Experience template created successfully', { diff --git a/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.test.ts b/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.test.ts index 414f686d..b6c5199c 100644 --- a/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.test.ts +++ b/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.test.ts @@ -78,6 +78,20 @@ describe('upsertExperienceTemplate', () => { expect(body.dataAssemblies).toEqual(dataAssemblies); }); + it('does not reintroduce viewports from a viewport-free experience template', async () => { + mockExperienceTemplateGet.mockResolvedValue({ + ...mockExperienceTemplate, + viewports: undefined, + }); + mockExperienceTemplateUpsert.mockResolvedValue(mockExperienceTemplate); + + await upsertExperienceTemplateTool(mockConfig)({ ...mockArgs, version: 1 }); + + expect(mockExperienceTemplateUpsert.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects a stale version', async () => { mockExperienceTemplateGet.mockResolvedValue(mockExperienceTemplate); // sys.version === 1 diff --git a/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.ts b/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.ts index 8d148e14..ee9309aa 100644 --- a/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.ts +++ b/packages/mcp-tools/src/tools/exo/experience-templates/upsertExperienceTemplate.ts @@ -16,6 +16,10 @@ import { TreeNodeSchema, ExoMetadataSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayload, + type ViewportOptionalPayload, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const UpsertExperienceTemplateToolParams = BaseToolSchema.extend({ @@ -91,32 +95,38 @@ export function upsertExperienceTemplateTool(config: ContentfulConfig) { ); } + const viewports = args.viewports ?? current.viewports; + + const experienceTemplateData = { + sys: { + id: current.sys.id, + type: 'ExperienceTemplate', + version: current.sys.version, + }, + name: args.name ?? current.name, + description: args.description ?? current.description, + ...(viewports !== undefined && { viewports }), + contentProperties: args.contentProperties ?? current.contentProperties, + designProperties: args.designProperties ?? current.designProperties, + ...((args.componentTree ?? current.componentTree) + ? { componentTree: args.componentTree ?? current.componentTree } + : {}), + ...((args.slots ?? current.slots) + ? { slots: args.slots ?? current.slots } + : {}), + ...((args.metadata ?? current.metadata) + ? { metadata: args.metadata ?? current.metadata } + : {}), + ...(current.dataAssemblies + ? { dataAssemblies: current.dataAssemblies } + : {}), + } satisfies ViewportOptionalPayload< + Parameters[1] + >; + const experienceTemplate = await contentfulClient.experienceTemplate.upsert( params, - { - sys: { - id: current.sys.id, - type: 'ExperienceTemplate', - version: current.sys.version, - }, - name: args.name ?? current.name, - description: args.description ?? current.description, - viewports: args.viewports ?? current.viewports, - contentProperties: args.contentProperties ?? current.contentProperties, - designProperties: args.designProperties ?? current.designProperties, - ...((args.componentTree ?? current.componentTree) - ? { componentTree: args.componentTree ?? current.componentTree } - : {}), - ...((args.slots ?? current.slots) - ? { slots: args.slots ?? current.slots } - : {}), - ...((args.metadata ?? current.metadata) - ? { metadata: args.metadata ?? current.metadata } - : {}), - ...(current.dataAssemblies - ? { dataAssemblies: current.dataAssemblies } - : {}), - }, + asViewportOptionalCmaPayload(experienceTemplateData), ); return createSuccessResponse('Experience template updated successfully', { diff --git a/packages/mcp-tools/src/tools/exo/experiences/createExperience.test.ts b/packages/mcp-tools/src/tools/exo/experiences/createExperience.test.ts index a23052de..3f0e14c8 100644 --- a/packages/mcp-tools/src/tools/exo/experiences/createExperience.test.ts +++ b/packages/mcp-tools/src/tools/exo/experiences/createExperience.test.ts @@ -39,6 +39,27 @@ describe('createExperience', () => { expect(result.content[0].text).toContain('Experience created successfully'); }); + it('creates a viewport-free experience with flattened design properties', async () => { + const viewportFreeArgs = { ...createArgs, viewports: undefined }; + mockExperienceCreate.mockResolvedValue(mockExperience); + + await createExperienceTool(mockConfig)({ + ...viewportFreeArgs, + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + + expect(mockExperienceCreate.mock.calls[0][1]).toMatchObject({ + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + expect(mockExperienceCreate.mock.calls[0][1]).not.toHaveProperty( + 'viewports', + ); + }); + it('rejects writes to a protected environment', async () => { const tool = createExperienceTool( createMockConfig({ protectedEnvironments: ['test-environment'] }), diff --git a/packages/mcp-tools/src/tools/exo/experiences/createExperience.ts b/packages/mcp-tools/src/tools/exo/experiences/createExperience.ts index 3b0013a3..d103b692 100644 --- a/packages/mcp-tools/src/tools/exo/experiences/createExperience.ts +++ b/packages/mcp-tools/src/tools/exo/experiences/createExperience.ts @@ -11,11 +11,16 @@ import { import { ViewportSchema, ExperienceMetadataSchema, + DesignPropertyValueSchema, DimensionedDesignPropertyValueSchema, ExperienceContentBindingsSchema, ExperienceSlotNodeSchema, ExperienceTemplateResourceLinkSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayloadWithFlattenedDesignProperties, + type ViewportOptionalPayloadWithFlattenedDesignProperties, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const CreateExperienceToolParams = BaseToolSchema.extend({ @@ -26,12 +31,21 @@ export const CreateExperienceToolParams = BaseToolSchema.extend({ ), viewports: z .array(ViewportSchema) - .describe('Viewport definitions for the experience (may be empty)'), + .optional() + .describe( + 'Optional viewport definitions for the experience. Omit for viewport-free experiences.', + ), designProperties: z - .record(z.string(), DimensionedDesignPropertyValueSchema) + .record( + z.string(), + z.union([ + DesignPropertyValueSchema, + DimensionedDesignPropertyValueSchema, + ]), + ) .describe( - 'Design property values keyed by property ID. Each value is a dimensioned map ' + - '(viewport ID → design value). May be an empty object.', + 'Design property values keyed by property ID. When viewports are provided, each value is a dimensioned map ' + + '(viewport ID → design value); without viewports, use a direct design value. May be an empty object.', ), contentBindings: ExperienceContentBindingsSchema.optional().describe( 'Optional content bindings linking this experience to a data assembly', @@ -58,18 +72,22 @@ export function createExperienceTool(config: ContentfulConfig) { const contentfulClient = createExoToolClient(config, args); + const experienceData = { + name: args.name, + description: args.description, + experienceTemplate: args.experienceTemplate, + ...(args.viewports !== undefined && { viewports: args.viewports }), + designProperties: args.designProperties, + ...(args.contentBindings && { contentBindings: args.contentBindings }), + ...(args.slots && { slots: args.slots }), + ...(args.metadata && { metadata: args.metadata }), + } satisfies ViewportOptionalPayloadWithFlattenedDesignProperties< + Parameters[1] + >; + const experience = await contentfulClient.experience.create( { spaceId: args.spaceId, environmentId: args.environmentId }, - { - name: args.name, - description: args.description, - experienceTemplate: args.experienceTemplate, - viewports: args.viewports, - designProperties: args.designProperties, - ...(args.contentBindings && { contentBindings: args.contentBindings }), - ...(args.slots && { slots: args.slots }), - ...(args.metadata && { metadata: args.metadata }), - }, + asViewportOptionalCmaPayloadWithFlattenedDesignProperties(experienceData), ); return createSuccessResponse('Experience created successfully', { diff --git a/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.test.ts b/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.test.ts index d4c73d4c..034e4c3f 100644 --- a/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.test.ts +++ b/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.test.ts @@ -14,7 +14,10 @@ describe('upsertExperience', () => { it('reads the current experience before updating (read-before-write)', async () => { mockExperienceGet.mockResolvedValue(mockExperience); - mockExperienceUpsert.mockResolvedValue({ ...mockExperience, name: 'Renamed' }); + mockExperienceUpsert.mockResolvedValue({ + ...mockExperience, + name: 'Renamed', + }); const tool = upsertExperienceTool(mockConfig); const result = await tool({ ...mockArgs, version: 1, name: 'Renamed' }); @@ -43,7 +46,9 @@ describe('upsertExperience', () => { it('preserves unspecified fields from the existing experience', async () => { mockExperienceGet.mockResolvedValue({ ...mockExperience, - designProperties: { color: { _: { type: 'ManualDesignValue', value: 'red' } } }, + designProperties: { + color: { _: { type: 'ManualDesignValue', value: 'red' } }, + }, }); mockExperienceUpsert.mockResolvedValue(mockExperience); @@ -56,6 +61,25 @@ describe('upsertExperience', () => { }); }); + it('preserves flattened design properties without reintroducing viewports', async () => { + mockExperienceGet.mockResolvedValue({ + ...mockExperience, + viewports: undefined, + designProperties: { + color: { type: 'ManualDesignValue', value: 'red' }, + }, + }); + mockExperienceUpsert.mockResolvedValue(mockExperience); + + await upsertExperienceTool(mockConfig)({ ...mockArgs, version: 1 }); + + const [, body] = mockExperienceUpsert.mock.calls[0]; + expect(body).not.toHaveProperty('viewports'); + expect(body.designProperties).toEqual({ + color: { type: 'ManualDesignValue', value: 'red' }, + }); + }); + it('rejects a stale version', async () => { mockExperienceGet.mockResolvedValue(mockExperience); // sys.version === 1 diff --git a/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.ts b/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.ts index 231fbf61..6dd47c5c 100644 --- a/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.ts +++ b/packages/mcp-tools/src/tools/exo/experiences/upsertExperience.ts @@ -11,10 +11,15 @@ import { import { ViewportSchema, ExperienceMetadataSchema, + DesignPropertyValueSchema, DimensionedDesignPropertyValueSchema, ExperienceContentBindingsSchema, ExperienceSlotNodeSchema, } from '../../../types/exoSchemas.js'; +import { + asViewportOptionalCmaPayloadWithFlattenedDesignProperties, + type ViewportOptionalPayloadWithFlattenedDesignProperties, +} from '../../../types/cmaViewportCompatibility.js'; import type { ContentfulConfig } from '../../../config/types.js'; export const UpsertExperienceToolParams = BaseToolSchema.extend({ @@ -34,7 +39,13 @@ export const UpsertExperienceToolParams = BaseToolSchema.extend({ .optional() .describe('Viewport definitions; replaces existing viewports if provided'), designProperties: z - .record(z.string(), DimensionedDesignPropertyValueSchema) + .record( + z.string(), + z.union([ + DesignPropertyValueSchema, + DimensionedDesignPropertyValueSchema, + ]), + ) .optional() .describe( 'Design property values keyed by property ID; replaces existing if provided', @@ -82,7 +93,9 @@ export function upsertExperienceTool(config: ContentfulConfig) { ); } - const experience = await contentfulClient.experience.upsert(params, { + const viewports = args.viewports ?? current.viewports; + + const experienceData = { sys: { id: current.sys.id, type: 'Experience', @@ -90,7 +103,7 @@ export function upsertExperienceTool(config: ContentfulConfig) { }, name: args.name ?? current.name, description: args.description ?? current.description, - viewports: args.viewports ?? current.viewports, + ...(viewports !== undefined && { viewports }), designProperties: args.designProperties ?? current.designProperties, ...((args.contentBindings ?? current.contentBindings) ? { contentBindings: args.contentBindings ?? current.contentBindings } @@ -101,7 +114,14 @@ export function upsertExperienceTool(config: ContentfulConfig) { ...((args.metadata ?? current.metadata) ? { metadata: args.metadata ?? current.metadata } : {}), - }); + } satisfies ViewportOptionalPayloadWithFlattenedDesignProperties< + Parameters[1] + >; + + const experience = await contentfulClient.experience.upsert( + params, + asViewportOptionalCmaPayloadWithFlattenedDesignProperties(experienceData), + ); return createSuccessResponse('Experience updated successfully', { experience, diff --git a/packages/mcp-tools/src/types/cmaViewportCompatibility.ts b/packages/mcp-tools/src/types/cmaViewportCompatibility.ts new file mode 100644 index 00000000..4032fcd6 --- /dev/null +++ b/packages/mcp-tools/src/types/cmaViewportCompatibility.ts @@ -0,0 +1,52 @@ +import type { ComponentTypeViewport } from 'contentful-management'; +import type { z } from 'zod'; +import type { + DesignPropertyValueSchema, + DimensionedDesignPropertyValueSchema, +} from './exoSchemas.js'; + +type DesignPropertyValue = z.infer; +type DimensionedDesignPropertyValue = z.infer< + typeof DimensionedDesignPropertyValueSchema +>; + +/** + * The CMA supports viewport-free ExO entities, but the currently consumed CMA + * SDK version still marks `viewports` as required. Keep the exception local to + * the SDK call boundary while callers validate the rest of the payload with + * `satisfies`. + */ +export type ViewportOptionalPayload = Omit & { + viewports?: ComponentTypeViewport[]; +}; + +/** + * Viewport-free Experiences and Experience Fragments use flattened design + * property values. The currently consumed CMA SDK only models their legacy, + * dimensioned shape. + */ +export type ViewportOptionalPayloadWithFlattenedDesignProperties = + Omit & { + viewports?: ComponentTypeViewport[]; + designProperties: Record< + string, + DesignPropertyValue | DimensionedDesignPropertyValue + >; + }; + +export function asViewportOptionalCmaPayload< + Payload extends { viewports?: ComponentTypeViewport[] }, +>(payload: ViewportOptionalPayload): Payload { + return payload as Payload; +} + +export function asViewportOptionalCmaPayloadWithFlattenedDesignProperties< + Payload extends { + viewports?: ComponentTypeViewport[]; + designProperties: Record; + }, +>( + payload: ViewportOptionalPayloadWithFlattenedDesignProperties, +): Payload { + return payload as Payload; +}