iron-cms 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/builds/iron.css +255 -218
  4. data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
  5. data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
  6. data/app/controllers/iron/api/base_controller.rb +3 -0
  7. data/app/controllers/iron/api/mcp_controller.rb +119 -0
  8. data/app/controllers/iron/api/openapi_controller.rb +1 -8
  9. data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
  10. data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
  11. data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
  12. data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
  13. data/app/mcp/iron/mcp/error.rb +5 -0
  14. data/app/mcp/iron/mcp/forbidden.rb +9 -0
  15. data/app/mcp/iron/mcp/invalid_request.rb +5 -0
  16. data/app/mcp/iron/mcp/paginator.rb +19 -0
  17. data/app/mcp/iron/mcp/server.rb +38 -0
  18. data/app/mcp/iron/mcp/tool_context.rb +114 -0
  19. data/app/mcp/iron/mcp/tools/base.rb +61 -0
  20. data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
  21. data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
  22. data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
  23. data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
  24. data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
  25. data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
  26. data/app/mcp/iron/mcp/tools/search.rb +25 -0
  27. data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
  28. data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
  29. data/app/models/iron/api/openapi_spec.rb +7 -0
  30. data/app/models/iron/content/download_budget.rb +31 -0
  31. data/app/models/iron/content.rb +201 -32
  32. data/app/models/iron/field_definition.rb +7 -1
  33. data/app/models/iron/field_definitions/block.rb +3 -1
  34. data/app/models/iron/field_definitions/block_list.rb +3 -1
  35. data/app/models/iron/integration.rb +11 -0
  36. data/app/models/iron/oauth/client.rb +63 -0
  37. data/app/models/iron/oauth/grant.rb +67 -0
  38. data/app/models/iron/oauth/resource.rb +27 -0
  39. data/app/models/iron/oauth/scope.rb +13 -0
  40. data/app/models/iron/oauth/secret.rb +13 -0
  41. data/app/models/iron/oauth/token.rb +123 -0
  42. data/app/models/iron/ssrf_protection.rb +82 -0
  43. data/app/views/iron/authentication/_brandmark.html.erb +5 -0
  44. data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
  45. data/app/views/iron/sessions/new.html.erb +1 -5
  46. data/app/views/layouts/iron/application.html.erb +3 -0
  47. data/app/views/layouts/iron/authentication.html.erb +3 -0
  48. data/config/locales/en.yml +12 -0
  49. data/config/locales/it.yml +12 -0
  50. data/config/routes.rb +1 -0
  51. data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
  52. data/lib/generators/iron/install/install_generator.rb +19 -0
  53. data/lib/iron/engine.rb +6 -0
  54. data/lib/iron/oauth_body_limit.rb +52 -0
  55. data/lib/iron/version.rb +1 -1
  56. metadata +50 -2
@@ -0,0 +1,114 @@
1
+ module Iron
2
+ module Mcp
3
+ class ToolContext
4
+ def list_content_types
5
+ ContentType.order(:handle).map do |content_type|
6
+ {
7
+ "handle" => content_type.handle,
8
+ "name" => content_type.name,
9
+ "kind" => content_type.single? ? "single" : "collection",
10
+ "web_published" => content_type.web_publishing_enabled?,
11
+ "base_path" => content_type.base_path.presence,
12
+ "title_field" => content_type.title_field_definition&.handle
13
+ }.compact
14
+ end
15
+ end
16
+
17
+ def describe_schema
18
+ Rails.cache.fetch(Iron::Api::OpenapiSpec.cache_key, expires_in: 1.hour) do
19
+ Iron::Api::OpenapiSpec.new.to_h
20
+ end
21
+ end
22
+
23
+ def list_entries(handle:, route: nil, after: nil, per_page: nil)
24
+ return Iron::Content.get(handle, route:, locale: locale_code) if route.present?
25
+
26
+ content_type = ContentType.find_by!(handle:)
27
+ serialize_collection(Paginator.new(after:, per_page:).call(content_type.entries))
28
+ end
29
+
30
+ def get_entry(handle:, id: nil, route: nil)
31
+ Iron::Content.get(handle, id:, route:, locale: locale_code)
32
+ end
33
+
34
+ def search(query:, after: nil, per_page: nil)
35
+ raise InvalidRequest, "query is required" if query.blank?
36
+
37
+ scope = Entry.search(query, locale: Current.locale)
38
+ serialize_collection(Paginator.new(after:, per_page:).call(scope))
39
+ end
40
+
41
+ def create_entry(handle:, content:, route: nil)
42
+ authorize_write!
43
+ Iron::Content.create(handle, content, route:, locale: locale_code, actor: Current.user, allow_local_files: false)
44
+ end
45
+
46
+ def update_entry(handle:, content:, id: nil, route: nil)
47
+ authorize_write!
48
+ Iron::Content.update(handle, content, id:, route:, locale: locale_code, actor: Current.user, allow_local_files: false)
49
+ end
50
+
51
+ def delete_entry(handle:, id: nil, route: nil)
52
+ authorize_write!
53
+ Iron::Content.delete(handle, id:, route:, actor: Current.user)
54
+ end
55
+
56
+ def upload_asset(url: nil, data: nil, filename: nil, content_type: nil)
57
+ authorize_write!
58
+ blob = build_blob(url:, data:, filename:, content_type:)
59
+
60
+ {
61
+ "signed_id" => blob.signed_id,
62
+ "filename" => blob.filename.to_s,
63
+ "content_type" => blob.content_type,
64
+ "byte_size" => blob.byte_size
65
+ }
66
+ end
67
+
68
+ private
69
+
70
+ def authorize_write!
71
+ raise Forbidden unless Current.user&.can_write?
72
+ end
73
+
74
+ def locale_code
75
+ Current.locale&.code
76
+ end
77
+
78
+ def serialize_collection(page)
79
+ {
80
+ "data" => page.records.map { |entry| Iron::Content.serialize(entry) },
81
+ "_pagination" => {
82
+ "per_page" => page.pagination.per_page,
83
+ "next_cursor" => page.pagination.next_cursor,
84
+ "has_more" => page.pagination.has_more
85
+ }
86
+ }
87
+ end
88
+
89
+ def build_blob(url:, data:, filename:, content_type:)
90
+ if url.present?
91
+ Iron::Content.download(url, filename:, content_type:)
92
+ elsif data.present?
93
+ blob_from_base64(data, filename:, content_type:)
94
+ else
95
+ raise InvalidRequest, "Provide either a url or base64 data"
96
+ end
97
+ end
98
+
99
+ def blob_from_base64(data, filename:, content_type:)
100
+ payload = data.sub(/\Adata:[^;,]*;base64,/, "").gsub(/\s/, "")
101
+ decoded = Base64.strict_decode64(payload)
102
+ raise InvalidRequest, "data exceeds #{Iron::Content::MAX_DOWNLOAD_BYTES} bytes" if decoded.bytesize > Iron::Content::MAX_DOWNLOAD_BYTES
103
+
104
+ ActiveStorage::Blob.create_and_upload!(
105
+ io: StringIO.new(decoded),
106
+ filename: filename.presence || "upload",
107
+ content_type:
108
+ )
109
+ rescue ArgumentError
110
+ raise InvalidRequest, "data must be valid base64"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,61 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class Base < ::MCP::Tool
5
+ class << self
6
+ def call(server_context:, **arguments)
7
+ structured = perform(context(server_context), **arguments)
8
+ success(structured)
9
+ rescue Iron::Mcp::Error, Iron::Content::Error, ActiveRecord::RecordNotFound => error
10
+ failure(error)
11
+ rescue => error
12
+ Rails.error.report(error, handled: true, source: "iron.mcp")
13
+ internal_failure
14
+ end
15
+
16
+ def perform(_context, **_arguments)
17
+ raise NotImplementedError, "#{name} must implement .perform"
18
+ end
19
+
20
+ private
21
+
22
+ def context(server_context)
23
+ server_context[:iron]
24
+ end
25
+
26
+ def success(structured)
27
+ ::MCP::Tool::Response.new(
28
+ [ { type: "text", text: as_text(structured) } ],
29
+ structured_content: structured
30
+ )
31
+ end
32
+
33
+ def failure(error)
34
+ ::MCP::Tool::Response.new(
35
+ [ { type: "text", text: error.message } ],
36
+ structured_content: error_details(error),
37
+ error: true
38
+ )
39
+ end
40
+
41
+ def internal_failure
42
+ ::MCP::Tool::Response.new(
43
+ [ { type: "text", text: "Something went wrong while handling this request." } ],
44
+ error: true
45
+ )
46
+ end
47
+
48
+ def error_details(error)
49
+ return { "errors" => error.errors } if error.is_a?(Iron::Content::InvalidContent)
50
+
51
+ nil
52
+ end
53
+
54
+ def as_text(structured)
55
+ structured.is_a?(String) ? structured : JSON.pretty_generate(structured)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class CreateEntry < Base
5
+ tool_name "create_entry"
6
+ description <<~TEXT
7
+ Create a new entry of a collection content type. `content` is a JSON object of
8
+ field handles to values, matching the type's write schema from `describe_schema`.
9
+ File fields accept an upload via `{ "_file": "https://..." }` (a URL or a
10
+ `signed_id` from `upload_asset`). Returns the created entry, or per-field errors
11
+ when validation fails.
12
+ TEXT
13
+ input_schema(
14
+ properties: {
15
+ handle: { type: "string", description: "Collection content type handle" },
16
+ content: { type: "object", description: "Field handles to values (see describe_schema)" },
17
+ route: { type: "string", description: "Optional route for web-published types" }
18
+ },
19
+ required: [ "handle", "content" ]
20
+ )
21
+
22
+ def self.perform(context, handle:, content:, route: nil)
23
+ context.create_entry(handle:, content:, route:)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class DeleteEntry < Base
5
+ tool_name "delete_entry"
6
+ description <<~TEXT
7
+ Delete an entry, purging its file attachments. Identify a collection entry by `id`
8
+ or `route`. This cannot be undone.
9
+ TEXT
10
+ input_schema(
11
+ properties: {
12
+ handle: { type: "string", description: "Content type handle" },
13
+ id: { type: "integer", description: "Entry id (collection types)" },
14
+ route: { type: "string", description: "Entry route (web-published types)" }
15
+ },
16
+ required: [ "handle" ]
17
+ )
18
+
19
+ def self.perform(context, handle:, id: nil, route: nil)
20
+ context.delete_entry(handle:, id:, route:)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class DescribeSchema < Base
5
+ tool_name "describe_schema"
6
+ description <<~TEXT
7
+ Return the OpenAPI schema describing every content type's read and write shape
8
+ (fields, types, validations). This is the source of truth for the `content`
9
+ payloads accepted by `create_entry` and `update_entry`. The schema reflects the
10
+ current CMS configuration and changes when the content model changes.
11
+ TEXT
12
+ input_schema(properties: {})
13
+
14
+ def self.perform(context)
15
+ context.describe_schema
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class GetEntry < Base
5
+ tool_name "get_entry"
6
+ description <<~TEXT
7
+ Fetch a single entry's full content. Identify it by `id` or by `route` for a
8
+ collection type; for a single-type content type, omit both.
9
+ TEXT
10
+ input_schema(
11
+ properties: {
12
+ handle: { type: "string", description: "Content type handle" },
13
+ id: { type: "integer", description: "Entry id (collection types)" },
14
+ route: { type: "string", description: "Entry route (web-published types)" }
15
+ },
16
+ required: [ "handle" ]
17
+ )
18
+
19
+ def self.perform(context, handle:, id: nil, route: nil)
20
+ context.get_entry(handle:, id:, route:)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class ListContentTypes < Base
5
+ tool_name "list_content_types"
6
+ description <<~TEXT
7
+ List the content types defined in this Iron CMS, with their handle, kind
8
+ (single or collection), whether they are web-published, and their title field.
9
+ Use this first to discover what content exists, then `describe_schema` for the
10
+ exact fields of a type.
11
+ TEXT
12
+ input_schema(properties: {})
13
+
14
+ def self.perform(context)
15
+ context.list_content_types
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class ListEntries < Base
5
+ tool_name "list_entries"
6
+ description <<~TEXT
7
+ List entries of a content type (newest first), paginated. Pass `after` with the
8
+ `next_cursor` from a previous response to page forward. Pass `route` instead to
9
+ fetch a single web-published entry by its route rather than listing.
10
+ TEXT
11
+ input_schema(
12
+ properties: {
13
+ handle: { type: "string", description: "Content type handle, e.g. \"post\"" },
14
+ route: { type: "string", description: "Fetch one entry by route instead of listing" },
15
+ after: { type: "string", description: "Pagination cursor from a previous response" },
16
+ per_page: { type: "integer", description: "Entries per page (1-100, default 25)" }
17
+ },
18
+ required: [ "handle" ]
19
+ )
20
+
21
+ def self.perform(context, handle:, route: nil, after: nil, per_page: nil)
22
+ context.list_entries(handle:, route:, after:, per_page:)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class Search < Base
5
+ tool_name "search"
6
+ description <<~TEXT
7
+ Search entries across all content types by text, paginated. Pass `after` with the
8
+ `next_cursor` from a previous response to page forward.
9
+ TEXT
10
+ input_schema(
11
+ properties: {
12
+ query: { type: "string", description: "Text to search for" },
13
+ after: { type: "string", description: "Pagination cursor from a previous response" },
14
+ per_page: { type: "integer", description: "Results per page (1-100, default 25)" }
15
+ },
16
+ required: [ "query" ]
17
+ )
18
+
19
+ def self.perform(context, query:, after: nil, per_page: nil)
20
+ context.search(query:, after:, per_page:)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class UpdateEntry < Base
5
+ tool_name "update_entry"
6
+ description <<~TEXT
7
+ Update an entry's content. Identify a collection entry by `id` or `route`; for a
8
+ single-type content type, omit both to upsert it. `content` is a partial JSON
9
+ object of field handles to new values (see `describe_schema`); omitted fields are
10
+ left unchanged. Returns the updated entry, or per-field errors when validation fails.
11
+ TEXT
12
+ input_schema(
13
+ properties: {
14
+ handle: { type: "string", description: "Content type handle" },
15
+ content: { type: "object", description: "Field handles to new values" },
16
+ id: { type: "integer", description: "Entry id (collection types)" },
17
+ route: { type: "string", description: "Entry route (web-published types)" }
18
+ },
19
+ required: [ "handle", "content" ]
20
+ )
21
+
22
+ def self.perform(context, handle:, content:, id: nil, route: nil)
23
+ context.update_entry(handle:, content:, id:, route:)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ module Iron
2
+ module Mcp
3
+ module Tools
4
+ class UploadAsset < Base
5
+ tool_name "upload_asset"
6
+ description <<~TEXT
7
+ Upload a file and get back a `signed_id` to assign to a file field in
8
+ `create_entry`/`update_entry`. Provide either a `url` to fetch, or base64 `data`
9
+ for inline binary (a `data:` URI prefix is accepted). When you already have a URL,
10
+ you can skip this and pass `{ "_file": "https://..." }` directly in the content.
11
+ TEXT
12
+ input_schema(
13
+ properties: {
14
+ url: { type: "string", description: "http(s) URL to download" },
15
+ data: { type: "string", description: "Base64-encoded file contents" },
16
+ filename: { type: "string", description: "Filename to store (recommended)" },
17
+ content_type: { type: "string", description: "MIME type, e.g. image/png" }
18
+ }
19
+ )
20
+
21
+ def self.perform(context, url: nil, data: nil, filename: nil, content_type: nil)
22
+ context.upload_asset(url:, data:, filename:, content_type:)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,6 +1,13 @@
1
1
  module Iron
2
2
  module Api
3
3
  class OpenapiSpec
4
+ def self.cache_key
5
+ sources = [ ContentType, FieldDefinition, BlockDefinition ]
6
+ latest = sources.filter_map { |model| model.maximum(:updated_at) }.max
7
+ revisions = sources.sum(&:count)
8
+ "iron/openapi/#{latest&.utc&.strftime('%s%6N')}-#{revisions}"
9
+ end
10
+
4
11
  def to_h
5
12
  {
6
13
  openapi: "3.1.0",
@@ -0,0 +1,31 @@
1
+ module Iron
2
+ class Content
3
+ # A single write request shares one download budget: a cap on how many
4
+ # _file directives it may ingest and a wall-clock deadline across all of
5
+ # its downloads. Per-read timeouts alone would let a hostile server drip
6
+ # a byte per interval and hold a worker indefinitely.
7
+ class DownloadBudget
8
+ MAX_FILES = 25
9
+ MAX_SECONDS = 60
10
+
11
+ def initialize(files: MAX_FILES, seconds: MAX_SECONDS)
12
+ @files = files
13
+ @deadline = now + seconds
14
+ end
15
+
16
+ def charge_file!
17
+ @files -= 1
18
+ raise Error, "too many _file directives in one request (max #{MAX_FILES})" if @files.negative?
19
+ end
20
+
21
+ def verify_deadline!(url)
22
+ raise Error, "could not download #{url}: download deadline exceeded" if now > @deadline
23
+ end
24
+
25
+ private
26
+ def now
27
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ end
29
+ end
30
+ end
31
+ end