Skip to main content
Glama

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

TableJSON Schema
NameRequiredDescriptionDefault
modelYesModel ID
entriesYesContent entries to save

Implementation Reference

  • 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()
        }
      },
    )
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so description carries full burden. Discloses critical git auto-commit behavior and persistence side effects. Explains ID auto-generation for collections and field ignorance patterns. Missing return value description and error handling behavior for a mutation tool.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Four dense sentences with zero waste. Front-loaded purpose statement followed by structured model-kind breakdown using parallel syntax. Critical git warning placed at end as imperative constraint. Every clause delivers essential usage information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Comprehensive coverage of input variations for complex polymorphic content models. Addresses git integration consequences. Missing output semantics (what the tool returns on success—generated IDs? confirmation?) which is notable given no output schema exists.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% establishing baseline of 3. Description adds significant value by explaining polymorphic entry formats across four model kinds—semantic context the schema cannot capture (e.g., 'data keys are the identities' for DICTIONARY, 'body' key convention for DOCUMENT).

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with specific verb 'Save' + resource 'content entries', immediately clarifying the write operation. Distinguishes from sibling 'contentrain_model_save' by focusing on content entries rather than models, and from 'contentrain_content_list/delete' via the save operation.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit field requirements for four model kinds (DICTIONARY, COLLECTION, DOCUMENT, SINGLETON), including which fields are ignored vs required. Warns against manual git file editing. Lacks explicit differentiation from 'contentrain_bulk' and 'contentrain_apply' siblings for batch operations.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Contentrain/ai'

If you have feedback or need assistance with the MCP directory API, please join our Discord server