LightCMS is a lightweight, self-hosted content management system built in Go. It uses MongoDB for data storage and generates static HTML pages for public content serving.
Key URLs:
- Admin Dashboard:
/cm - Public Site:
/ - MCP Server:
bin/lightcms-mcp(stdio transport)
IMPORTANT: All website content operations MUST go through the MCP server. Do NOT:
- Write scripts to directly modify the database
- Use Go code to create/edit/delete content
- Bypass the MCP server for any content management tasks
If the MCP server is not available or not working, ASK the user for permission before attempting any content changes through other means.
If the user requests a content operation and the MCP server is not yet running, start it using the convenience script:
./bin/lightcms-mcp-wrapper.shThis wrapper script sets up the required environment variables (LIGHTCMS_CONFIG_DIR) and launches the MCP server. The MCP server uses stdio transport, so it will be connected automatically once started.
- Creating, editing, publishing, or deleting content
- Managing templates and their HTML layouts
- Uploading or managing assets (images, CSS, JS, documents)
- Updating theme settings (colors, fonts, header/footer HTML)
- Managing redirects, folders, and collections
- Viewing or reverting content versions
- Site configuration changes
- Adding new features to LightCMS itself
- Fixing bugs in the application
- Changing application behavior or logic
- Adding new MCP tools
- Modifying database schemas or indexes
- Security improvements
The Claude Desktop MCP integration uses a local binary (bin/lightcms-mcp) that connects to the remote server. The binary encodes all tool definitions — if you add or remove MCP tools without rebuilding it, Claude Desktop will see stale/missing tools.
After any change to cmd/mcp/ or internal/mcp/:
go build -o bin/lightcms-mcp ./cmd/mcpThen restart Claude Desktop to reload the MCP server. Until it's restarted, Cowork will still use the old binary.
Run the setup script from the lightcms directory:
./setup-mcp.shThis builds the MCP server, creates the wrapper script, and registers it with Claude Code.
Manual setup (if needed):
# Build the MCP server
go build -o bin/lightcms-mcp ./cmd/mcp
# Register with Claude Code (use the wrapper script, not the binary directly)
claude mcp add --transport stdio lightcms-mcp -- /path/to/lightcms/lightcms-mcp-wrapper.shAfter registering, restart Claude Code. You can verify the server is connected:
- Run
/mcpin Claude Code to check status - Run
claude mcp listin terminal to see registered servers
Once connected, you can ask Claude to manage your content naturally:
- "Create a new blog post about AI"
- "List all my published content"
- "Update the homepage hero image"
- "Delete the /random page"
Binary: bin/lightcms-mcp
Config: Uses same config.dev.json or environment variables as main server
Content (23 tools): list_content, get_content, create_content, update_content, update_content_by_path, publish_content, publish_multiple, unpublish_content, delete_content, restore_content, preview_content, get_content_versions, get_content_version, revert_to_version, bulk_create_content, bulk_update_content, bulk_field_operation, export_content, get_backlinks
Templates (5 tools): list_templates, get_template, create_template, update_template, delete_template
Snippets (5 tools): list_snippets, get_snippet, create_snippet, update_snippet, delete_snippet
Assets (6 tools): list_assets, list_asset_folders, get_asset, upload_asset, upload_asset_from_url, delete_asset
Search (7 tools): search_content, search_replace_preview, search_replace_execute, scoped_search_replace_preview, scoped_search_replace_execute, end_user_search, reindex_embeddings
Settings (18 tools): get_theme, update_theme, get_theme_versions, get_theme_version, revert_theme_to_version, pin_theme_version, unpin_theme_version, get_site_config, update_site_config, list_redirects, create_redirect, update_redirect, delete_redirect, list_folders, create_folder, get_folder, delete_folder, list_collections, create_collection, get_collection, update_collection, delete_collection, regenerate_all_content
Forks (8 tools): list_forks, create_fork, get_fork, fork_page, remove_fork_page, merge_fork, archive_fork, delete_fork
Comments (3 tools, v6.0+): list_comments, create_comment, delete_comment
Approvals (11 tools, v6.0+): list_approval_workflows, get_approval_workflow, create_approval_workflow, update_approval_workflow, delete_approval_workflow, list_approval_requests, get_approval_request, submit_for_approval, approve_request, reject_request, cancel_approval_request
The search_replace_execute tool is destructive and modifies content permanently. Before using it, you MUST:
- ALWAYS run
search_replace_previewfirst to see exactly what will be changed - Show the user the preview results, including:
- Number of pages affected
- Which pages are published vs drafts
- Sample excerpts showing what will change
- Explicitly ask for user confirmation before running
search_replace_execute - Never execute search/replace without user consent, even if the user asked for a "quick fix"
Example interaction:
User: "Update all links from http to https"
Assistant: "Let me preview what would change..."
[Run search_replace_preview]
Assistant: "This would affect 15 pages (12 published, 3 drafts) with 47 total replacements. Here are the affected pages: [list]. Should I proceed with these changes?"
User: "Yes, go ahead"
[Run search_replace_execute]
When creating or updating content via MCP or admin UI, the following markup features are processed at page generation time:
[[Page Title]]— links to a page by its title (case-insensitive lookup)[[Page Title|display text]]— link with custom label[[/full/path]]— links to a page by its URL path[[/full/path|display text]]— path link with custom label- Broken links render as
<span class="broken-link">text</span> - Links auto-update when a page's title or path changes (via
UpdateWikilinksOnRename)
[[include:snippet-name]]— embeds a named snippet inline- Snippet name must match the
namefield in the snippets collection - Recursion depth limit: 3 levels; cycles are detected and dropped
- Add
{{.lc_toc}}in a template's HTML layout where the TOC should appear - Auto-generates
<nav class="lc-toc"><ul>...</ul></nav>from page headings - All headings automatically get
id=attributes for anchor navigation
- Set a template field type to
markdownfor GFM rendering at publish time - Supports tables, strikethrough, task lists, autolinks
- Script policy controls whether raw HTML/scripts are allowed (see Script Policy below)
- Mention
#tagnamein any content field to automatically tag the page - Tag names: start with a letter, alphanumeric/underscore/hyphen, max 50 chars
- Tags feed into
lc:queryindex pages
- Use snippets for reusable UI components: callout boxes, CTA sections, disclaimers, badge patterns
- Snippet variables:
{{.Title}},{{.FullPath}},{{.Slug}},{{.MetaDescription}},{{.PublishedAt}} - Reference snippets in content with
[[include:snippet-name]]or in template layouts vialc:querydirectives
Site-wide setting markdown_script_policy (configurable via update_site_config):
"all"— default; all roles may use raw HTML including<script>in markdown fields"admin_only"— editors' content is sanitized; admin content passes through unchanged"none"— all content sanitized regardless of author role
The sanitizer (bluemonday) strips: <script>, <iframe>, <form>, <input>, event handlers (onclick=, etc.), javascript: URIs. All other HTML is preserved.
# Build main HTTP server
go build -o bin/lightcms ./cmd/server
# Build MCP server
go build -o bin/lightcms-mcp ./cmd/mcp
# Run main server (requires config.dev.json or env vars)
./bin/lightcms
# Run MCP server (stdio transport)
./bin/lightcms-mcplightcms/
├── cmd/
│ ├── server/main.go # Main HTTP server entry point
│ └── mcp/main.go # MCP server entry point
├── config/config.go # Configuration (env vars or JSON)
├── internal/
│ ├── auth/ # Session-based authentication
│ ├── database/mongo.go # MongoDB connection & helpers
│ ├── errors/ # Environment-aware error handling
│ ├── handlers/ # HTTP request handlers (~5000 lines)
│ ├── mcp/ # MCP tool implementations
│ ├── middleware/ # Security headers, file validation
│ ├── models/models.go # Data models & default templates
│ └── services/ # Business logic layer
├── templates/ # Admin UI HTML templates
├── static/ # CSS, JS, images
└── content/generated/ # Static HTML output
All business logic goes through services in internal/services/:
- ContentService: CRUD with automatic versioning, static page generation
- TemplateService: Template management with content regeneration
- AssetService: File uploads with validation
- SettingsService: Theme, config, redirects, folders, collections
- UserService: User CRUD, password management, credential validation
- AuditService: Sync/async audit log writes, filtered listing
- APIKeyService: API key management with user ownership
Every content update automatically creates a version. Versions are stored in content_versions collection. Use revert_to_version to restore previous versions.
IMPORTANT: When updating content via MCP, ALWAYS include a version_comment parameter with a concise description of what changed, even if the user doesn't explicitly provide one. This makes version history useful for tracking changes over time.
Examples:
- "Updated page title"
- "Revised introduction paragraph"
- "Added new section on security"
- "Fixed typo in heading"
- "Replaced hero image"
Published content is rendered to content/generated/{path}.html using template HTML + theme header/footer. Regeneration happens automatically on:
- Content publish/update
- Template layout change
- Theme header/footer change
content- Content items with full_path (unique index)content_versions- Version history (includes modified_by, modified_by_email)templates- Content templates with fields + HTML layoutfolders- Content organization hierarchycollections- Content grouping by categoryassets- File metadata (binary stored on filesystem)redirects- URL redirect rulessettings- Theme, config settingsusers- User accounts with email/password/role (unique email index)audit_logs- Audit trail (who did what, when; 365-day TTL)api_keys- API keys with optional user_id ownershipcontact_messages- Form submissionslogin_attempts- Rate limiting data
- Services:
ContentService,TemplateService - Handlers:
CreateContent,UpdateTemplate - DB helpers:
FindOne,UpdateOne,InsertOne - Receivers:
h(Handler),s(Service),db(DB)
return fmt.Errorf("context description: %w", err)// Filters use bson.M
filter := bson.M{"_id": id, "deleted": bson.M{"$ne": true}}
// Updates use bson.M with operators
update := bson.M{"$set": bson.M{"title": title, "updated_at": time.Now()}}
// Sorting uses bson.D for order
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})Always pass context as first parameter for DB/service methods.
- admin: Full access — manage users, templates, settings, audit logs, all API keys
- editor: Create/edit/delete/publish content, upload/delete assets, manage own API keys
- viewer: Read-only access to content, templates, assets, settings
- Login with email + password (migrated from single-admin password)
- Session stores: user_id, user_email, user_role
- Force password change on first login with temporary password
- API keys carry the permissions of their owning user
On first startup with empty users collection, creates admin user from existing settings.admin password hash. Set LIGHTCMS_ADMIN_EMAIL env var (default: admin@localhost).
go run cmd/resetpw/main.go [email] # Reset specific user, or first admin if no email given- CSRF protection on all
/cmroutes (Gorilla CSRF) - Session cookies: SameSite=Strict, 24-hour expiry, Secure in production
- File uploads: Extension whitelist + MIME validation
- Path traversal protection on all file operations
- Login rate limiting: Escalating lockout (10→1min, 15→5min, 20+→15min)
- Passwords: bcrypt with cost=12
- RBAC permission checks on all admin handlers and REST API endpoints
- Audit logging on all mutations (async, 365-day TTL auto-cleanup)
Environment Variables (Production):
MONGO_URI- MongoDB connection stringSESSION_SECRET- 32+ char secretBASE_URL- Public URL (e.g., https://example.com)PORT- Server port (default: 80)ENV- "production" or "development"SECURE_COOKIES- "true" for HTTPS
JSON Config (Development):
config.dev.json:
{
"port": "8082",
"mongo_uri": "mongodb+srv://...",
"env": "development",
"session_secret": "dev-secret-change-in-prod",
"base_url": "http://localhost:8082",
"secure_cookies": false
}7 built-in system templates:
- Blog Post
- Press Release
- Explanatory Page
- Blank Page
- Homepage
- Concept Page
- Standard Page
Template fields support types: text, textarea, richtext, date, image, select
LightCMS supports large-scale content operations (2,000+ pages) via optimized bulk APIs. These guidelines apply to programmatic SEO, mass content migration, site-wide link fixing, and similar large-scale tasks.
- Use
bulk_create_content(up to 100 items/call) instead of callingcreate_contentin a loop - Uses MongoDB
InsertManywith unordered mode — one failure doesn't abort the batch - Published items get parallel HTML generation (10 concurrent)
- Set
upsert: trueto update existing pages instead of failing on duplicates - Example: creating 2,000 pages = 20 batch calls of 100
create_contentacceptsupsert: true— if a page exists at the same path, it updates instead of failing- Eliminates the most common failure mode in retry scenarios
bulk_create_contentalso supportsupsert: truefor batch idempotency
search_replace_previewandsearch_replace_executeaccept apairsarray for multi-pair mode- Each page is scanned once with all pairs applied in order — O(pages) instead of O(pairs × pages)
- Critical for operations like fixing hundreds of broken links in a single pass
- Returns
pages_scanned,pages_modified,total_replacementscounts - Pairs are applied in array order — replacement from pair 1 may create text that pair 2 matches
- Static HTML generation uses content hashing (SHA-256) — unchanged pages are skipped automatically
RegenerateAllContent(triggered by theme/template changes) clears all hashes first to force full regen- For search/replace with
auto_republish: true, only modified pages get republished
list_contentsupportslimitandoffsetparameters for paginated results- Returns
{items, total, limit, offset, has_more}envelope whenlimitis set - Default (no limit) returns all items — backward compatible
- Max limit: 500 per request
- For sites with 2,000+ pages, use pagination to avoid large JSON responses
- API: 300 requests/minute per bearer token (sliding window)
- Burst: 20 requests/second per bearer token (prevents runaway scripts)
- Bulk endpoints: additional per-endpoint limits (regenerate, search/replace, bulk update)
- When rate limited, response includes
Retry-Afterheader
- Use batch APIs —
bulk_create_contentandbulk_update_contentover individual calls - Use multi-pair search/replace — preview first, then execute with all pairs in one call
- Set
auto_republish: trueon search/replace to avoid a separate publish step - Include
version_commenton all bulk operations for readable version history - Use
upsert: truefor retry-safe content creation (idempotent) - Paginate list_content with
limit: 100to avoid loading 2,000+ items at once - Don't exceed 100 items per bulk call — this is enforced server-side
Deployed to Fly.io (metavert-cms app, machine d890122a371528). Uses environment variables for configuration. Health check at /health.
Deploy procedure — always use the deploy script:
./deploy.shThe script builds the image via fly deploy --detach, extracts the image ref, destroys any stuck new machines, then updates the actual running machine directly via fly machines update. This is necessary because the running machine (d890122a371528) is a legacy non-Launch machine — fly deploy doesn't see it as a managed machine and creates new stuck orphan machines instead of updating it. The machine ID is hardcoded in deploy.sh.
- Create
config.dev.jsonwith MongoDB Atlas connection go build -o bin/lightcms ./cmd/server./bin/lightcms- Access admin at http://localhost:8082/cm (default: admin@localhost / admin123)