contentrain_content_save
Save content entries to Contentrain MCP with automatic git commits. Supports dictionary, collection, document, and singleton models with locale-specific data and markdown content handling.
Instructions
Save content entries. Entry format varies by model kind: DICTIONARY — provide "locale" and "data" (flat key-value, all string values); "id" and "slug" are ignored; data keys are the identities. COLLECTION — provide "locale" and "data"; "id" is optional (auto-generated if omitted); "slug" is ignored. DOCUMENT — provide "slug" (required), "locale", and "data"; use the "body" key inside data for markdown content. SINGLETON — provide only "locale" and "data". Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| model | Yes | Model ID | |
| entries | Yes | Content entries to save |
Implementation Reference
- packages/mcp/src/tools/content.ts:12-161 (handler)Handler and definition for the contentrain_content_save tool. It handles configuration checks, model loading, validation, transaction management, writing content using `writeContent`, git commit, and post-save validation.
server.tool( 'contentrain_content_save', 'Save content entries. Entry format varies by model kind: DICTIONARY — provide "locale" and "data" (flat key-value, all string values); "id" and "slug" are ignored; data keys are the identities. COLLECTION — provide "locale" and "data"; "id" is optional (auto-generated if omitted); "slug" is ignored. DOCUMENT — provide "slug" (required), "locale", and "data"; use the "body" key inside data for markdown content. SINGLETON — provide only "locale" and "data". Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.', { model: z.string().describe('Model ID'), entries: z.array(z.object({ id: z.string().optional().describe('Entry ID (collection only, auto-generated if omitted)'), slug: z.string().optional().describe('Slug (document only)'), locale: z.string().optional().describe('Locale code (defaults to config default)'), data: z.record(z.string(), z.unknown()).describe('Content data. For documents, include "body" key for markdown content.'), publish_at: z.string().optional().describe('ISO 8601 date for scheduled publishing (stored in meta)'), expire_at: z.string().optional().describe('ISO 8601 date for scheduled expiry (stored in meta, must be after publish_at)'), })).describe('Content entries to save'), }, async (input) => { const config = await readConfig(projectRoot) if (!config) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Project not initialized. Run contentrain_init first.' }) }], isError: true, } } const model = await readModel(projectRoot, input.model) if (!model) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Model "${input.model}" not found` }) }], isError: true, } } // Validate locales and scheduling fields before starting transaction for (const entry of input.entries) { if (entry.locale && !config.locales.supported.includes(entry.locale)) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Locale "${entry.locale}" is not supported. Supported: [${config.locales.supported.join(', ')}]`, }) }], isError: true, } } // Validate publish_at / expire_at if (entry.publish_at !== undefined) { const d = new Date(entry.publish_at) if (Number.isNaN(d.getTime())) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Invalid publish_at date: "${entry.publish_at}". Must be a valid ISO 8601 date string.`, }) }], isError: true, } } } if (entry.expire_at !== undefined) { const d = new Date(entry.expire_at) if (Number.isNaN(d.getTime())) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Invalid expire_at date: "${entry.expire_at}". Must be a valid ISO 8601 date string.`, }) }], isError: true, } } } if (entry.publish_at !== undefined && entry.expire_at !== undefined) { if (new Date(entry.expire_at) <= new Date(entry.publish_at)) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `expire_at ("${entry.expire_at}") must be after publish_at ("${entry.publish_at}").`, }) }], isError: true, } } } // Merge scheduling fields into data so meta-manager picks them up if (entry.publish_at !== undefined) entry.data['publish_at'] = entry.publish_at if (entry.expire_at !== undefined) entry.data['expire_at'] = entry.expire_at } // Branch health gate const health = await checkBranchHealth(projectRoot) if (health.blocked) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: health.message, action: 'blocked', hint: 'Merge or delete old contentrain/* branches before creating new ones.', }, null, 2) }], isError: true, } } const branch = buildBranchName('content', input.model) const tx = await createTransaction(projectRoot, branch) try { let results: Awaited<ReturnType<typeof writeContent>> let entryIds: string[] = [] await tx.write(async (wt) => { results = await writeContent(wt, model, input.entries, config) entryIds = results!.map(r => r.id ?? r.slug ?? r.locale).filter(Boolean) as string[] }) await tx.commit(`[contentrain] content: ${input.model}`) const gitResult = await tx.complete({ tool: 'contentrain_content_save', model: input.model, locale: input.entries[0]?.locale, entries: entryIds, }) // Run real validation after save — don't fake it const validationResult = await validateProject(projectRoot, { model: input.model }) return { content: [{ type: 'text' as const, text: JSON.stringify({ status: 'committed', message: 'Content saved and committed to git. Do NOT manually edit .contentrain/ files.', results: results!, git: { branch, action: gitResult.action, commit: gitResult.commit }, validation: { valid: validationResult.valid, errors: validationResult.issues.filter(i => i.severity === 'error').map(i => i.message), }, context_updated: true, next_steps: [ ...(!validationResult.valid ? ['WARNING: Content has validation errors — run contentrain_validate for details'] : []), ...(model.kind === 'collection' ? ['Use contentrain_content_list to verify', 'Add more entries or publish'] : ['Use contentrain_content_list to verify']), ], }, null, 2) }], } } catch (error) { await tx.cleanup() return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}`, }) }], isError: true, } } finally { await tx.cleanup() } }, )