Skip to content

Conversation

@ornsteinfilip
Copy link
Member

@ornsteinfilip ornsteinfilip commented Jan 24, 2026

Folio MCP Server - Implementation Plan

Status: Production Ready (v1.2) ✅

Last tested: 2026-01-24 21:01 UTC

Completed Features

  • Bearer token authentication via HasMcpToken concern
  • CRUD operations for Pages, Articles, Projects, Files
  • Tiptap content storage (with correct wrapper structure)
  • Translation tools (extract/apply)
  • File upload from URL with validation
  • Resource listing
  • Prompts for guided workflows (translate_page, create_content, edit_metadata)
  • HTTP transport via Folio::Api::McpController
  • Configurable resources DSL
  • Audit logging infrastructure
  • Cover image assignment via cover_id field
  • Image validation (MIME detection from content, processability check)
  • STI base class support (records with nil type)

Architecture Overview

Components

lib/folio/mcp/
├── configuration.rb          # DSL for configuring MCP resources
└── server_factory.rb         # Builds MCP::Server with tools & resources

app/lib/folio/mcp/
├── tools/
│   ├── base.rb               # Shared tool helpers (success_response, error_response)
│   ├── list_records.rb       # list_pages, list_articles, etc.
│   ├── get_record.rb         # get_page, get_article, etc.
│   ├── create_record.rb      # create_page, create_article, etc.
│   ├── update_record.rb      # update_page, update_article, etc.
│   ├── upload_file.rb        # upload_file from URL
│   ├── extract_translatable_texts.rb
│   └── apply_translations.rb
├── serializers/
│   └── record.rb             # JSON serialization for records
├── resources/
│   └── handler.rb            # Resource read handler
├── prompts/
│   ├── translate_page.rb
│   ├── create_content.rb
│   └── edit_metadata.rb
├── tiptap_text_extractor.rb  # Extract texts from tiptap JSON
└── tiptap_schema_generator.rb

app/controllers/folio/api/
└── mcp_controller.rb         # HTTP transport endpoint

app/models/concerns/folio/
└── has_mcp_token.rb          # User token management

Configuration Example

# config/initializers/folio_mcp.rb
Folio::Mcp.configure do |config|
  config.enabled = true
  config.locales = [:cs, :en]

  config.resource :pages do |r|
    r.model = "Folio::Page"
    r.fields = [:title, :slug, :meta_title, :meta_description, :locale, :published]
    r.tiptap_fields = [:perex, :content]
    r.allowed_actions = [:read, :create, :update]
  end

  config.resource :files do |r|
    r.model = "Folio::File"
    r.fields = [:alt, :title, :tags]
    r.uploadable = true
    r.allowed_actions = [:read]
  end
end

Critical Findings

Tiptap Content Structure

The tiptap content MUST be wrapped in a specific structure when saving:

{
  "tiptap_content": {
    "type": "doc",
    "content": [...]
  }
}

NOT directly:

{
  "type": "doc",
  "content": [...]
}

This is due to Folio::Tiptap::TIPTAP_CONTENT_JSON_STRUCTURE[:content] requiring the "tiptap_content" key.

Attribute Filtering Fixed

filter_allowed_attrs now properly handles nil config values and converts symbols:

allowed_fields = (config[:fields] || []) + (config[:tiptap_fields] || [])
attrs.slice(*allowed_fields.map(&:to_sym)).compact

Phase 2: Improvements

Priority 1: Fix Translation Extraction for Wrapped Structure

Issue: extract_translatable_texts may return empty when tiptap is wrapped
Location: app/lib/folio/mcp/tiptap_text_extractor.rb
Task: Handle wrapped structure {"tiptap_content": {...}} automatically

def initialize(tiptap_json)
  @tiptap = tiptap_json.is_a?(String) ? JSON.parse(tiptap_json) : tiptap_json
  # Unwrap if needed
  @tiptap = @tiptap["tiptap_content"] if @tiptap.is_a?(Hash) && @tiptap["tiptap_content"]
  @texts = []
end

Priority 2: Tiptap Structure Documentation in Tool Descriptions

Task: Update MCP tools description to document required tiptap structure
Location: lib/folio/mcp/server_factory.rb - tool descriptions

Add to update tool description:

description: "Update an existing #{singular_name}. Tiptap fields must use wrapped structure: {\"tiptap_content\": {\"type\": \"doc\", \"content\": [...]}}"

Priority 3: Search Files Tool

Issue: Cannot assign cover images without knowing file_id
Solution: Add search_files tool

MCP::Tool.define(
  name: "search_files",
  description: "Search files in media library",
  input_schema: {
    properties: {
      query: { type: "string", description: "Search query (filename, alt text)" },
      type: { type: "string", enum: ["image", "document", "video", "audio"] },
      limit: { type: "integer" }
    }
  }
)

Priority 4: Better Error Messages

Task: Improve error messages to be more actionable

  • Show allowed field names on validation errors
  • Show expected tiptap structure on format errors
  • Include example payloads in error responses

Priority 5: Cover Image Assignment ✅ DONE

Cover images are now assignable via cover_id field:

# In configuration
config.resource :projects do |r|
  r.cover_field = :background_graphics  # Specifies which placement to use
end

# In update call
update_project(id: 39, cover_id: 200)  # Assigns image ID 200 as cover

Implementation:

  • server_factory.rb adds cover_id to input schema when cover_field configured
  • update_record.rb handles has_one :through placement associations

Priority 6: Robust File Upload ✅ DONE

Upload validation ensures only valid, processable images are accepted:

  1. MIME detection from content - Uses Marcel or magic bytes, NOT HTTP headers
  2. Image processability check - Validates dimensions readable via Dragonfly
  3. Automatic file_name - Sets upload_XXXX.jpg based on detected type
  4. Clear error messages - Reports why upload failed
# Example validation flow:
# 1. Download file to tempfile
# 2. Detect MIME from magic bytes (FF D8 FF = JPEG, 89 PNG = PNG, etc.)
# 3. Reject if unsupported type
# 4. For images: create temp file with extension, try to read dimensions
# 5. If dimensions unreadable -> reject as invalid image
# 6. Set file_name with proper extension
# 7. Save to database

Phase 3: Advanced Features

Bulk Operations

  • bulk_update_records - Update multiple records at once
  • bulk_translate - Translate multiple fields/records

Preview URLs

  • Add preview_url to serialized output
  • Support draft/unpublished content preview

Atom Management

  • list_atom_types - Available atom types
  • add_atom_to_page - Add atoms to existing pages
  • reorder_atoms - Change atom order

Webhook Support

  • Notify external systems on content changes
  • Integration with CI/CD pipelines

Rate Limiting

  • Per-user rate limits
  • Configurable in initializer

Site Context

  • Support multi-site with site selector
  • Site-scoped tokens

Testing Checklist

Manual Tests Performed (2026-01-24)

Test Result
initialize ✅ Works
tools/list ✅ 15 tools listed
list_pages ✅ Returns pages
get_page ✅ Returns single page
get_page (nil type) ✅ Works with base Folio::Page
update_page (metadata) ✅ Updates title, meta
update_page (tiptap) ✅ Works with wrapped structure
create_article ✅ Creates record
update_article (tiptap) ✅ Works with wrapped structure
create_project ✅ Creates record
update_project ✅ Works
update_project (cover_id) ✅ Assigns cover image
resources/list ✅ 5 resources
prompts/list ✅ 3 prompts
upload_file (valid JPEG) ✅ Works, sets file_name
upload_file (invalid file) ✅ Rejected with error
upload_file (text file) ✅ Rejected as unsupported type
extract_translatable_texts ⚠️ Works but needs wrapped structure handling
apply_translations ✅ Works
validation errors ✅ Returns localized messages

Automated Tests

Location: test/lib/folio/mcp/

  • compatibility_test.rb - Config validation
  • configuration_test.rb - DSL tests
  • tiptap_text_extractor_test.rb - Extraction tests
  • tools/apply_translations_test.rb - Translation tests

Run tests:

bundle exec rails test test/lib/folio/mcp/

MCP Client Configuration

Cursor IDE

Location: .cursor/mcp.json

{
  "mcpServers": {
    "folio-local": {
      "type": "http",
      "url": "http://localhost:3000/folio/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN_HERE"
      }
    }
  }
}

Generate Token

rails 'folio:mcp:generate_token[user@example.com]'

Or in Rails console:

user = Folio::User.find_by(email: "user@example.com")
token = user.generate_mcp_token!
puts token

Using Images in Tiptap Content

To include uploaded images in tiptap content, use the cover_placement_attributes with file_id:

Single Image

{
  "type": "folioTiptapNode",
  "attrs": {
    "data": {
      "cover_placement_attributes": { "file_id": 123 }
    },
    "type": "SinfinDigital::Tiptap::Node::Images::SingleImage",
    "version": 1
  }
}

Image in Card

{
  "type": "folioTiptapNode",
  "attrs": {
    "data": {
      "title": "Card Title",
      "content": "{\"type\":\"doc\",\"content\":[...]}",
      "cover_placement_attributes": { "file_id": 123 }
    },
    "type": "SinfinDigital::Tiptap::Node::Cards::Large",
    "version": 1
  }
}

Multiple Images (Gallery)

{
  "type": "folioTiptapNode",
  "attrs": {
    "data": {
      "title": "Gallery Title",
      "image_placements_attributes": [
        { "file_id": 123 },
        { "file_id": 124 },
        { "file_id": 125 }
      ]
    },
    "type": "SinfinDigital::Tiptap::Node::Images::MasonryGallery",
    "version": 1
  }
}

Workflow for Adding Images

  1. Upload image: upload_file(url: "https://example.com/image.jpg", alt: "Description")
  2. Get returned id (e.g., 123)
  3. Use id in tiptap content as file_id
  4. Update record with tiptap content

API Reference

Tools

Tool Description
list_{resources} List records with pagination (limit, offset, locale, published)
get_{resource} Get single record by ID
create_{resource} Create new record
update_{resource} Update existing record
list_{resource}_versions List version history for a record (requires versioned: true)
get_{resource}_version Get a specific historical version with preview URL
restore_{resource}_version Restore record to a previous version
upload_file Upload file from URL
extract_translatable_texts Extract texts from tiptap for translation
apply_translations Apply translated texts back to tiptap

Resources

URI Description
folio://pages List of pages
folio://articles List of articles
folio://projects List of projects
folio://files List of files
folio://tiptap/schema Tiptap node schema

Prompts

Prompt Description
translate_page Guided workflow for page translation
create_content Guide for creating new content
edit_metadata Guide for editing SEO metadata

Version History

Configuration

Enable versioning for a resource:

# config/initializers/folio_mcp.rb
config.resources = {
  pages: {
    model: "Folio::Page",
    fields: %i[title slug],
    allowed_actions: %i[read create update],
    versioned: true  # Enable version history tools
  }
}

# Also enable Folio page auditing:
# config/initializers/folio.rb
Rails.application.config.folio_pages_audited = true

Workflow: Review and Restore Previous Version

1. list_page_versions(id: 123)
   → Returns list of versions with preview URLs

2. Visit preview_url in browser to review version visually
   → URL format: /folio/console/pages/123/revision/5

3. Optionally get_page_version(id: 123, version: 5)
   → Returns full content at that version for comparison

4. restore_page_version(id: 123, version: 5)
   → Restores content, creates new version (6) in history

Version Response Format

{
  "version_info": {
    "version": 5,
    "action": "update",
    "created_at": "2026-01-24T10:30:00Z",
    "user": { "id": 1, "email": "editor@example.com" },
    "changes": ["title", "tiptap_content"],
    "preview_url": "https://example.com/folio/console/pages/123/revision/5",
    "restorable": true
  },
  "record": {
    "id": 123,
    "title": "Page title at version 5",
    ...
  }
}

Known Limitations

  1. No delete operations - Intentional for safety
  2. No image upload from base64 - Only URL-based upload (must be publicly accessible)
  3. No nested resource creation - e.g., page with atoms in one call
  4. Single-site context - Multi-site requires separate tokens
  5. No atom management - Cannot add/modify atoms via MCP yet
  6. No file replacement - Can upload new, cannot replace existing
  7. No URL generation - Cannot generate frontend URLs for records (use slug)

Security Considerations

  • Tokens are BCrypt hashed in database
  • Token prefix stored for identification
  • Tokens don't expire (consider adding expiration)
  • All operations are audit logged
  • Actions limited to configured allowed_actions
  • Fields limited to configured fields and tiptap_fields

Changelog

2026-01-25 (v1.2)

  • Version history tools - New tools for version management:
    • list_{resource}_versions - List all versions with pagination
    • get_{resource}_version - Get specific version content with preview URL
    • restore_{resource}_version - Restore to a previous version
  • Preview URLs - Historical versions include console preview URL
  • Configuration - New versioned: true option for resources
  • Authorization - Proper error responses (not crashes) for unauthorized access

2026-01-24 (v1.1)

  • Cover image assignment - cover_id field for has_one :through placements
  • Image upload validation - MIME detection from file content (magic bytes)
  • Image processability check - Verifies dimensions readable before save
  • Auto file_name - Sets proper extension based on detected MIME type
  • STI nil type fix - allowed_types check handles records with type: nil
  • Namespace collision fix - ::File.extname instead of File.extname in upload

2026-01-24 (v1.0)

  • Initial MCP server implementation
  • Fixed routing (namespace :folio instead of scope)
  • Fixed JSON Schema validation (removed empty required: [])
  • Fixed tool block signature (|server_context:, **kwargs|)
  • Fixed BCrypt require in HasMcpToken
  • Fixed filter_allowed_attrs for nil config values
  • Added load_components! for app/lib autoloading
  • Documented tiptap content wrapper structure requirement

Future Considerations

Performance

  • Pagination optimization for large datasets

mreq and others added 29 commits January 12, 2026 12:23
…ance method

Update test stubs to use instance method stubs instead of class method
stubs. Add migration notes to UPGRADING.md and CHANGELOG.md.
…r autosave checks

Updated instances in ConsoleUrlBarComponent, SimpleFormWrapComponent, and AutosaveInfoComponent to use `try` for `tiptap_autosave_enabled?` method calls, enhancing robustness against nil objects.
- Refactor TipTap SCSS variables to CSS custom properties.
- Move theme class to .f-tiptap-styles directly.
- Add support for per-record themes in ViewComponents.
- Remove redundant SCSS variables.
- Move float aside CSS variables from .f-tiptap-float to .f-tiptap-styles scope
- Add responsive variables for tablet/desktop breakpoints
- Update container queries to use responsive variables instead of hardcoded values
- Allows apps to override float values at theme level without selector duplication
Add video/x-m4v to allowed video formats and map it to video/mp4
for browser compatibility, similar to video/quicktime support.
Position buttons in paginated catalogues can now move items to adjacent pages.
When moving to previous page, automatically scrolls to bottom to show moved item.
Adds has_folio_positionable? class method to positionable concerns.
Allow string attributes to define a default proc that receives the node instance as an optional argument. The default value is displayed as a placeholder in the form input. Updated documentation to reflect proc-based defaults with flexible arity.
- Add factory for Folio::Cache::Version model
- Add tests for key presence and uniqueness validations
- Test error types instead of messages for locale independence
- Document Rails error testing best practices in AGENTS.md
- Add packs architecture with configurable enabled_packs
- Move cache feature to packs/cache pack
- Add packwerk configuration and rake tasks
- Extend packwerk to support engine paths outside Rails.root
- Add package.yml for core engine and cache pack
- Update documentation for packs architecture
- Move pack test loading from test_helper_base to test_helper (engine only)
- Unify test entry point: remove dummy test_helper, load dummy tests from engine test_helper
- Update all dummy test files to require engine test_helper
- Now 'rails test' runs all tests (engine + packs + dummy) in one command
Load all cache versions for current site once per request on first folio_cache usage, then filter from memory for subsequent calls. Moves cache_versions_hash to cache pack concern to avoid packwerk violations.
- Add expires_at column to cache versions for scheduled invalidation
- Implement ExpireJob for automatic cache invalidation when expires_at passes
- Add publishable extensions with folio_cache_expires_at methods
- Support block form in configure for temporary test configuration
- Add comprehensive tests for folio_cache_expires_at variants
- Refactor invalidator to extract data_to_upsert variable
- Update documentation with scheduled expiration details
…ries

Add --pack=<pack_name> option to all generators that include Folio::GeneratorBase.
When specified, files are generated into packs/<pack_name>/app/... instead of app/...

Updated generators:
- folio:component
- folio:atom
- folio:console:scaffold
- folio:console:catalogue
- folio:scaffold
- folio:mailer
- folio:search
- folio:page_singleton
- folio:prepared_atom
- folio:blog
- folio:tiptap:node
- folio:ui
- folio:devise
- folio:install
- folio:assets
- folio:cache_headers

Example usage:
  rails g folio:component cache/version --pack=cache
  # -> packs/cache/app/components/...
- Change cache pack routes from namespace :folio/:cache to namespace :cache with module option
- Routes now match polymorphic routing expectations (console_cache_versions_url)
- Add automatic locale loading for all enabled packs in engine
- Locale files in packs/*/config/locales are now loaded automatically
- Add invalidate controller action that bumps updated_at via Invalidator
- Add invalidate action button to versions index catalogue with reload icon
- Add flash message translations in cache locale files
- Add to_label method to Cache::Version model
- Add test for invalidate action
- Add invalidate_all collection action to invalidate all cache versions for current site
- Add clear_rails_cache action to clear Rails.cache
- Add buttons to index header for both actions with confirmation dialogs
- Update index_header helper to support block syntax for component customization
- Add pg_search_scope to Cache::Version model for search functionality
- Add tests for both new actions
- Update translations with proper Czech phrasing
- Add console_sidebar_before_site_packs_links hook for packs to add sidebar links
- Add cache versions link to sidebar above site settings when cache pack enabled
- Hide site cache clear button when cache pack is enabled
- Update sidebar cell to collect and merge pack links
- Add invalidation_metadata JSONB column to cache versions table
- Store metadata when invalidating cache (model info, manual actions with user)
- Display metadata in console index with component
- Show time ago for updated_at attribute
- Add i18n translations for metadata display
- Update tests to handle string keys from JSONB
@ornsteinfilip ornsteinfilip changed the base branch from master to petr/tiptap-multi-lang January 24, 2026 22:17
@ornsteinfilip ornsteinfilip requested a review from mreq January 24, 2026 22:17
Copy link
Member

@mreq mreq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nedokazu posoudit pouzitelnost, ale prosim, udelej to z petr/has-folio-tiptap-and-cache jako mcp pack https://github.com/sinfin/folio/blob/petr/has-folio-tiptap-and-cache/docs/packs.md

@ornsteinfilip
Copy link
Member Author

@mreq To je větev ve které už jsou překlady TipTapu? Používáme to teď na Sinfin webu už s překlady. cc @VladaTrefil

mreq and others added 17 commits January 26, 2026 10:55
- Add a new fetch method to Folio::Cache that supports cache versioning.
- The method constructs a cache key using the provided name and version keys.
- Default expiration time is set if not specified in options.
- The method delegates to Rails.cache.fetch for actual caching functionality.
…ords

Fix folio_cache_affects_published? to only return true when record is
currently published or was previously published (unpublishing case).
Previously it would return true for newly created unpublished records
because previous_changes included 'published' attribute.
…ersions

Bumped sidekiq-cron to version 2.0 and sidekiq to version 7.0 in the gemspec for improved functionality and compatibility.
Included connection_pool version 2.x in the gemspec to ensure compatibility, as version 3.0.x introduces breaking changes.
Added MCP (Model Context Protocol) server support, enabling AI agents to interact with CMS content. This includes new dependencies, a dedicated controller, tools for handling translations, and configuration for resource management. Updated user model to support MCP tokens and added necessary migrations and documentation.
Updated record retrieval and update methods to handle nil types for base classes without STI, improving type validation. Added validation for Tiptap content during record updates and implemented cover image assignment based on provided attributes. Enhanced file upload functionality with improved MIME type detection and validation for image processability, ensuring robust error handling and user feedback. Expanded documentation to include Tiptap content structure and validation guidelines.
Implemented eager loading for Tiptap node classes to ensure all descendants are populated. Updated content creation and update methods to support both simple and full wrapper formats for Tiptap JSON content. Added normalization and validation for Tiptap content, including deep stringification of keys for consistent handling. Enhanced documentation to clarify Tiptap content structure and usage of custom nodes.
Added support for versioning in the MCP, including tools to list, retrieve, and restore record versions. Implemented validation and authorization checks for versioning actions. Enhanced configuration to enable versioning for resources and updated documentation to reflect new features and usage examples. Introduced tests to ensure functionality and error handling for version-related operations.
Reformatted the extract_type method for improved readability by aligning the case statement and removing unnecessary line breaks. This enhances code clarity while maintaining existing functionality.
Included a relative require for the version file in the server factory to ensure versioning tools are accessible within the MCP module. This change supports the ongoing enhancements related to version management.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants