rails-markup 1.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "set"
5
+ require "digest/sha1"
6
+
7
+ module RailsMarkup
8
+ module ClientUuidMaintenance
9
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
10
+ UUID_INPUT_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
11
+ COLLISION_UUID_NAMESPACE = "102f1c26-3ad7-5b5c-871a-44c5f2392790"
12
+
13
+ module_function
14
+
15
+ def repair!(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
16
+ table = table_name.to_sym
17
+ return 0 unless connection.table_exists?(table)
18
+
19
+ connection.add_column(table, :client_uuid, :string, limit: 64) unless connection.column_exists?(table, :client_uuid)
20
+ repaired = repair_rows(connection, table)
21
+ ensure_unique_index(connection, table)
22
+ repaired
23
+ end
24
+
25
+ def invalid_count(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
26
+ table = table_name.to_sym
27
+ return 0 unless connection.table_exists?(table) && connection.column_exists?(table, :client_uuid)
28
+
29
+ quoted_table = connection.quote_table_name(table)
30
+ quoted_client_uuid = connection.quote_column_name(:client_uuid)
31
+ connection.select_values("SELECT #{quoted_client_uuid} FROM #{quoted_table}").count do |value|
32
+ !canonical_uuid?(value)
33
+ end
34
+ end
35
+
36
+ def verify!(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
37
+ table = table_name.to_sym
38
+ unless connection.table_exists?(table) && connection.column_exists?(table, :client_uuid)
39
+ raise "Rails Markup client UUID storage is not installed"
40
+ end
41
+
42
+ invalid = invalid_count(connection:, table_name:)
43
+ duplicates = casefold_duplicate_count(connection, table)
44
+ raise "Rails Markup has #{duplicates} case-fold duplicate client UUID(s)" if duplicates.positive?
45
+ raise "Rails Markup has #{invalid} annotation(s) without canonical client UUIDs" if invalid.positive?
46
+ unless connection.indexes(table).any? { |index| full_unique_client_uuid_index?(index) }
47
+ raise "Rails Markup client UUID unique index is missing"
48
+ end
49
+
50
+ true
51
+ end
52
+
53
+ def repair_rows(connection, table)
54
+ primary_key = connection.primary_key(table)
55
+ raise "Rails Markup annotations table must have a primary key" unless primary_key
56
+
57
+ quoted_table = connection.quote_table_name(table)
58
+ quoted_primary_key = connection.quote_column_name(primary_key)
59
+ quoted_client_uuid = connection.quote_column_name(:client_uuid)
60
+ rows = connection.select_rows(<<~SQL.squish)
61
+ SELECT #{quoted_primary_key}, #{quoted_client_uuid}
62
+ FROM #{quoted_table}
63
+ ORDER BY #{quoted_primary_key}
64
+ SQL
65
+ used = Set.new
66
+ assignments = []
67
+
68
+ rows.each do |id, current_uuid|
69
+ normalized = normalize_uuid(current_uuid)
70
+ desired = if normalized && used.add?(normalized)
71
+ normalized
72
+ elsif normalized
73
+ next_collision_uuid(used, normalized, id)
74
+ else
75
+ next_unique_uuid(used)
76
+ end
77
+ assignments << [id, current_uuid, desired] unless current_uuid == desired
78
+ end
79
+
80
+ reserved = Set.new(rows.filter_map { |_id, value| value.to_s.downcase.presence }).merge(used)
81
+ assignments.each do |id, _current_uuid, _desired|
82
+ temporary = next_unique_uuid(reserved)
83
+ update_uuid(connection, quoted_table, quoted_primary_key, quoted_client_uuid, id, temporary)
84
+ end
85
+ assignments.each do |id, _current_uuid, desired|
86
+ update_uuid(connection, quoted_table, quoted_primary_key, quoted_client_uuid, id, desired)
87
+ end
88
+ assignments.length
89
+ end
90
+ private_class_method :repair_rows
91
+
92
+ def canonical_uuid?(value)
93
+ value.is_a?(String) && UUID_PATTERN.match?(value)
94
+ end
95
+ private_class_method :canonical_uuid?
96
+
97
+ def normalize_uuid(value)
98
+ value.downcase if value.is_a?(String) && UUID_INPUT_PATTERN.match?(value)
99
+ end
100
+ private_class_method :normalize_uuid
101
+
102
+ def update_uuid(connection, table, primary_key, client_uuid, id, value)
103
+ connection.execute <<~SQL.squish
104
+ UPDATE #{table}
105
+ SET #{client_uuid} = #{connection.quote(value)}
106
+ WHERE #{primary_key} = #{connection.quote(id)}
107
+ SQL
108
+ end
109
+ private_class_method :update_uuid
110
+
111
+ def next_unique_uuid(used)
112
+ loop do
113
+ uuid = SecureRandom.uuid
114
+ return uuid if used.add?(uuid)
115
+ end
116
+ end
117
+ private_class_method :next_unique_uuid
118
+
119
+ def next_collision_uuid(used, normalized, id)
120
+ counter = 0
121
+ loop do
122
+ uuid = namespaced_uuid("casefold\0#{normalized}\0#{id}\0#{counter}")
123
+ return uuid if used.add?(uuid)
124
+ counter += 1
125
+ end
126
+ end
127
+ private_class_method :next_collision_uuid
128
+
129
+ def namespaced_uuid(name)
130
+ namespace = [COLLISION_UUID_NAMESPACE.delete("-")].pack("H*")
131
+ bytes = Digest::SHA1.digest(namespace + name).bytes.first(16)
132
+ bytes[6] = (bytes[6] & 0x0f) | 0x50
133
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
134
+ hex = bytes.pack("C*").unpack1("H*")
135
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
136
+ end
137
+ private_class_method :namespaced_uuid
138
+
139
+ def ensure_unique_index(connection, table)
140
+ indexes = connection.indexes(table)
141
+ return if indexes.any? { |index| full_unique_client_uuid_index?(index) }
142
+
143
+ default_name = connection.index_name(table, column: [:client_uuid])
144
+ conflict = indexes.find { |index| index.name == default_name }
145
+ connection.remove_index(table, name: conflict.name) if conflict
146
+ connection.add_index(table, :client_uuid, unique: true, name: default_name)
147
+ end
148
+ private_class_method :ensure_unique_index
149
+
150
+ def casefold_duplicate_count(connection, table)
151
+ quoted_table = connection.quote_table_name(table)
152
+ quoted_client_uuid = connection.quote_column_name(:client_uuid)
153
+ normalized = connection.select_values("SELECT #{quoted_client_uuid} FROM #{quoted_table}").filter_map do |value|
154
+ normalize_uuid(value)
155
+ end
156
+ normalized.length - normalized.uniq.length
157
+ end
158
+ private_class_method :casefold_duplicate_count
159
+
160
+ def full_unique_client_uuid_index?(index)
161
+ return false unless index.unique && index.columns == ["client_uuid"]
162
+ return false unless !index.respond_to?(:where) || index.where.nil? || index.where.to_s.strip.empty?
163
+ return true unless index.respond_to?(:lengths)
164
+
165
+ lengths = index.lengths
166
+ client_length = case lengths
167
+ when Hash then lengths["client_uuid"] || lengths[:client_uuid]
168
+ when Array then lengths[index.columns.index("client_uuid")]
169
+ end
170
+ client_length.nil?
171
+ end
172
+ private_class_method :full_unique_client_uuid_index?
173
+ end
174
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ class Configuration
5
+ ALLOWED_ACCENTS = %w[indigo amber blue emerald rose].freeze
6
+ ALLOWED_POSITIONS = %w[tl tr br bl].freeze
7
+ ALLOWED_SIZES = %w[slim compact default].freeze
8
+
9
+ # Base controller class name for dashboard/API controllers.
10
+ # Set to a controller that provides authentication.
11
+ # Example: "AdminAuthController"
12
+ attr_accessor :base_controller_class
13
+
14
+ # Bearer token for the external API (MCP production tools).
15
+ # Set to nil to disable external API.
16
+ attr_accessor :api_token
17
+
18
+ # Database table name for annotations.
19
+ attr_accessor :table_name
20
+
21
+ # Accent color for the toolbar FAB and UI elements.
22
+ attr_reader :toolbar_accent
23
+
24
+ # Render the annotation toolbar system at all (FAB, panel, pins, sync).
25
+ # Set to false to disable rails-markup entirely for a request/environment.
26
+ # Default: true
27
+ attr_accessor :toolbar_enabled
28
+
29
+ # Show the floating action button (FAB) specifically.
30
+ # When false, the FAB is hidden but the toolbar system stays active — pins
31
+ # still render and the panel is reachable via its toggle. Use when the host
32
+ # provides its own trigger or wants read-only pins. Requires toolbar_enabled.
33
+ # Default: true
34
+ attr_accessor :fab_visible
35
+
36
+ # URL path for "Back to app" link in the dashboard header.
37
+ # Set to nil to hide the link.
38
+ # Example: "/admin"
39
+ attr_accessor :return_url
40
+
41
+ # Layout for the dashboard views.
42
+ # Set to a host app layout name to embed within that layout.
43
+ # Default: "rails_markup/application" (engine's own layout)
44
+ # Example: "admin"
45
+ attr_accessor :dashboard_layout
46
+
47
+ # Number of annotations per page on the dashboard.
48
+ attr_reader :per_page
49
+
50
+ # Method name (Symbol) or Proc to resolve the author's display name from the current_user.
51
+ # Symbol: calls user.public_send(method_name) — e.g. :name, :email, :display_name
52
+ # Proc: receives user, returns string — e.g. ->(u) { u.full_name }
53
+ # Default: :email
54
+ attr_accessor :author_name_method
55
+
56
+ # Proc called after an annotation is created. Receives the annotation record.
57
+ # Use for email/Slack/webhook notifications.
58
+ # Default: nil (no callback)
59
+ attr_accessor :on_create_callback
60
+
61
+ # Enable element screenshot capture in the toolbar.
62
+ # Default: true
63
+ attr_accessor :enable_screenshots
64
+
65
+ # FAB button position: "bl" (bottom-left), "br" (bottom-right),
66
+ # "tl" (top-left), "tr" (top-right). Default: "bl"
67
+ attr_reader :toolbar_position
68
+
69
+ # FAB button size: "default" (48px), "compact" (40px), "slim" (32px).
70
+ # Default: "default"
71
+ attr_reader :toolbar_size
72
+
73
+ # Health check poll interval in seconds.
74
+ # Default: 60
75
+ attr_reader :health_interval
76
+
77
+ def initialize
78
+ @base_controller_class = "RailsMarkup::ApplicationController"
79
+ @api_token = nil
80
+ @table_name = "rails_markup_annotations"
81
+ @per_page = 25
82
+ @toolbar_accent = "indigo"
83
+ @toolbar_enabled = true
84
+ @fab_visible = true
85
+ @toolbar_position = "bl"
86
+ @toolbar_size = "default"
87
+ @return_url = nil
88
+ @dashboard_layout = "rails_markup/application"
89
+ @author_name_method = :email
90
+ @on_create_callback = nil
91
+ @enable_screenshots = true
92
+ @health_interval = 60
93
+ end
94
+
95
+ def resolve_author_name(user)
96
+ return nil unless user
97
+
98
+ case author_name_method
99
+ when Symbol then user.respond_to?(author_name_method) ? user.public_send(author_name_method) : nil
100
+ when Proc then author_name_method.call(user)
101
+ end
102
+ rescue => e
103
+ Rails.logger.warn("[rails-markup] author_name_method error: #{e.message}") if defined?(Rails)
104
+ nil
105
+ end
106
+
107
+ def per_page=(value)
108
+ raise ArgumentError, "per_page must be a positive integer" unless value.is_a?(Integer) && value.positive?
109
+
110
+ @per_page = value
111
+ end
112
+
113
+ def toolbar_accent=(value)
114
+ unless ALLOWED_ACCENTS.include?(value.to_s)
115
+ raise ArgumentError, "toolbar_accent must be one of: #{ALLOWED_ACCENTS.join(', ')}"
116
+ end
117
+
118
+ @toolbar_accent = value.to_s
119
+ end
120
+
121
+ def toolbar_position=(value)
122
+ unless ALLOWED_POSITIONS.include?(value.to_s)
123
+ raise ArgumentError, "toolbar_position must be one of: #{ALLOWED_POSITIONS.join(', ')}"
124
+ end
125
+
126
+ @toolbar_position = value.to_s
127
+ end
128
+
129
+ def toolbar_size=(value)
130
+ unless ALLOWED_SIZES.include?(value.to_s)
131
+ raise ArgumentError, "toolbar_size must be one of: #{ALLOWED_SIZES.join(', ')}"
132
+ end
133
+
134
+ @toolbar_size = value.to_s
135
+ end
136
+
137
+ def health_interval=(value)
138
+ val = value.to_i
139
+ raise ArgumentError, "health_interval must be at least 10 (seconds)" unless val >= 10
140
+
141
+ @health_interval = val
142
+ end
143
+ end
144
+
145
+ class << self
146
+ def configuration
147
+ @configuration ||= Configuration.new
148
+ end
149
+
150
+ alias_method :config, :configuration
151
+
152
+ def configure
153
+ yield(configuration)
154
+ end
155
+
156
+ def reset_configuration!
157
+ @configuration = Configuration.new
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace RailsMarkup
6
+
7
+ initializer "rails_markup.configuration" do
8
+ RailsMarkup.configuration # ensure defaults are set
9
+ end
10
+
11
+ initializer "rails_markup.assets" do |app|
12
+ if app.config.respond_to?(:assets)
13
+ # toolbar.js is inlined via the toolbar partial, no separate asset needed
14
+ app.config.assets.precompile += %w[rails_markup/toolbar.js]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "json"
5
+
6
+ module RailsMarkup
7
+ # HTTP server providing REST API + SSE for the browser-side annotation controller.
8
+ # Wire-compatible with Agentation's HTTP API.
9
+ class HttpServer
10
+ attr_reader :port, :store
11
+
12
+ def initialize(store:, port: 4747, logger: nil)
13
+ @store = store
14
+ @port = port
15
+ @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
16
+ end
17
+
18
+ def start
19
+ @server = WEBrick::HTTPServer.new(
20
+ Port: @port,
21
+ Logger: @logger,
22
+ AccessLog: [],
23
+ DoNotReverseLookup: true
24
+ )
25
+
26
+ @server.mount("/", CorsServlet, @store)
27
+
28
+ trap("INT") { @server.shutdown }
29
+ trap("TERM") { @server.shutdown }
30
+
31
+ @server.start
32
+ end
33
+
34
+ def shutdown
35
+ @server&.shutdown
36
+ end
37
+ end
38
+
39
+ # Single servlet handling all routes with proper CORS support.
40
+ # WEBrick dispatches do_GET, do_POST, do_OPTIONS to us directly.
41
+ class CorsServlet < WEBrick::HTTPServlet::AbstractServlet
42
+ def initialize(server, store)
43
+ super(server)
44
+ @store = store
45
+ end
46
+
47
+ def do_OPTIONS(req, res)
48
+ cors(res)
49
+ res.status = 204
50
+ end
51
+
52
+ def do_GET(req, res)
53
+ cors(res)
54
+ route(req, res)
55
+ end
56
+
57
+ def do_POST(req, res)
58
+ cors(res)
59
+ route(req, res)
60
+ end
61
+
62
+ private
63
+
64
+ def route(req, res)
65
+ case req.path
66
+ when "/health"
67
+ json_response(res, { ok: true })
68
+
69
+ when "/pending"
70
+ pending = @store.all_pending
71
+ json_response(res, pending.map { |a| @store.serialize_annotation(a) })
72
+
73
+ when "/sessions"
74
+ if req.request_method == "GET"
75
+ sessions = @store.list_sessions
76
+ json_response(res, sessions.map { |s| @store.serialize_session(s) })
77
+ else
78
+ handle_create_session(req, res)
79
+ end
80
+
81
+ when %r{\A/sessions/([^/]+)/annotations\z}
82
+ handle_create_annotation(req, res, $1)
83
+
84
+ when %r{\A/sessions/([^/]+)/events\z}
85
+ handle_sse(req, res, $1)
86
+
87
+ when %r{\A/sessions/([^/]+)\z}
88
+ handle_get_session(res, $1)
89
+
90
+ when %r{\A/annotations/([^/]+)/resolve\z}
91
+ handle_annotation_action(req, res, $1, :resolve)
92
+
93
+ when %r{\A/annotations/([^/]+)/dismiss\z}
94
+ handle_annotation_action(req, res, $1, :dismiss)
95
+
96
+ when %r{\A/annotations/([^/]+)/acknowledge\z}
97
+ handle_annotation_action(req, res, $1, :acknowledge)
98
+
99
+ when %r{\A/annotations/([^/]+)/reply\z}
100
+ handle_annotation_action(req, res, $1, :reply)
101
+
102
+ else
103
+ not_found(res)
104
+ end
105
+ end
106
+
107
+ # --- Annotation actions ---
108
+
109
+ def handle_annotation_action(req, res, annotation_id, action)
110
+ body = parse_json(req)
111
+ result = case action
112
+ when :resolve
113
+ @store.resolve(annotation_id, summary: body["summary"])
114
+ when :dismiss
115
+ @store.dismiss(annotation_id, reason: body["reason"])
116
+ when :acknowledge
117
+ @store.acknowledge(annotation_id)
118
+ when :reply
119
+ @store.reply(annotation_id, message: body["message"])
120
+ end
121
+ return not_found(res) unless result
122
+
123
+ json_response(res, @store.serialize_annotation(result))
124
+ end
125
+
126
+ # --- Sessions ---
127
+
128
+ def handle_create_session(req, res)
129
+ body = parse_json(req)
130
+ session = @store.create_session(url: body["url"], metadata: body["metadata"])
131
+ json_response(res, @store.serialize_session(session), status: 201)
132
+ end
133
+
134
+ def handle_get_session(res, session_id)
135
+ session = @store.get_session(session_id)
136
+ return not_found(res) unless session
137
+
138
+ json_response(res, @store.serialize_session(session))
139
+ end
140
+
141
+ # --- Annotations ---
142
+
143
+ def handle_create_annotation(req, res, session_id)
144
+ body = parse_json(req)
145
+ annotation = @store.create_annotation(
146
+ session_id: session_id,
147
+ target: body["target"],
148
+ content: body["content"],
149
+ intent: body["intent"] || "change",
150
+ severity: body["severity"] || "suggestion",
151
+ selected_text: body["selectedText"],
152
+ metadata: body["metadata"]
153
+ )
154
+ return not_found(res) unless annotation
155
+
156
+ json_response(res, @store.serialize_annotation(annotation), status: 201)
157
+ end
158
+
159
+ # --- SSE ---
160
+
161
+ def handle_sse(_req, res, session_id)
162
+ session = @store.get_session(session_id)
163
+ return not_found(res) unless session
164
+
165
+ res.status = 200
166
+ res["Content-Type"] = "text/event-stream"
167
+ res["Cache-Control"] = "no-cache"
168
+ res["Connection"] = "keep-alive"
169
+ res["Access-Control-Allow-Origin"] = "*"
170
+
171
+ res.chunked = true
172
+ res.body = proc do |out|
173
+ sub = @store.subscribe(session_id) do |data|
174
+ event_type = data[:type] || "annotation_update"
175
+ out.write("event: #{event_type}\ndata: #{data.to_json}\n\n")
176
+ rescue Errno::EPIPE, IOError
177
+ @store.unsubscribe(sub)
178
+ end
179
+
180
+ deadline = Time.now + 1800 # 30 minutes max
181
+ loop do
182
+ break if Time.now >= deadline
183
+
184
+ sleep 15
185
+ out.write(": keepalive\n\n")
186
+ rescue Errno::EPIPE, IOError
187
+ break
188
+ end
189
+ ensure
190
+ @store.unsubscribe(sub) if defined?(sub) && sub
191
+ end
192
+ end
193
+
194
+ # --- Helpers ---
195
+
196
+ def cors(res)
197
+ port = @server[:Port] rescue 4747
198
+ res["Access-Control-Allow-Origin"] = "http://localhost:#{port}"
199
+ res["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
200
+ res["Access-Control-Allow-Headers"] = "Content-Type"
201
+ end
202
+
203
+ def json_response(res, data, status: 200)
204
+ res.status = status
205
+ res["Content-Type"] = "application/json"
206
+ res.body = data.to_json
207
+ end
208
+
209
+ def not_found(res)
210
+ res.status = 404
211
+ res["Content-Type"] = "application/json"
212
+ res.body = { error: "not_found" }.to_json
213
+ end
214
+
215
+ MAX_BODY_SIZE = 1_000_000 # 1MB
216
+
217
+ def parse_json(req)
218
+ return {} if req.body.nil?
219
+ return {} if req.body.bytesize > MAX_BODY_SIZE
220
+
221
+ JSON.parse(req.body)
222
+ rescue JSON::ParserError
223
+ {}
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RailsMarkup
8
+ # Proxies store operations to an existing HTTP server.
9
+ # Used when MCP starts but the HTTP port is already taken —
10
+ # delegates reads/writes to the running server's HTTP API.
11
+ class HttpStoreProxy
12
+ def initialize(base_url: "http://localhost:4747")
13
+ @base_url = base_url
14
+ @uri = URI.parse(base_url)
15
+ end
16
+
17
+ def list_sessions
18
+ get("/sessions") || []
19
+ end
20
+
21
+ def get_session(id)
22
+ get("/sessions/#{id}")
23
+ end
24
+
25
+ def create_session(url:, metadata: nil)
26
+ post("/sessions", { url: url, metadata: metadata })
27
+ end
28
+
29
+ def all_pending
30
+ get("/pending") || []
31
+ end
32
+
33
+ def pending_for_session(session_id)
34
+ session = get_session(session_id)
35
+ return [] unless session
36
+
37
+ (session["annotations"] || []).select { |a| a["status"] == "pending" }
38
+ end
39
+
40
+ def get_annotation(id)
41
+ # Search all sessions for this annotation (not just pending)
42
+ list_sessions.each do |session|
43
+ (session["annotations"] || []).each do |ann|
44
+ return ann if ann["id"] == id
45
+ end
46
+ end
47
+ nil
48
+ end
49
+
50
+ def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion", selected_text: nil, metadata: nil)
51
+ post("/sessions/#{session_id}/annotations", {
52
+ target: target, content: content, intent: intent,
53
+ severity: severity, selectedText: selected_text, metadata: metadata
54
+ })
55
+ end
56
+
57
+ def acknowledge(id)
58
+ post("/annotations/#{id}/acknowledge", {})
59
+ end
60
+
61
+ def resolve(id, summary: nil)
62
+ post("/annotations/#{id}/resolve", { summary: summary })
63
+ end
64
+
65
+ def dismiss(id, reason: nil)
66
+ post("/annotations/#{id}/dismiss", { reason: reason })
67
+ end
68
+
69
+ def reply(id, message:)
70
+ post("/annotations/#{id}/reply", { message: message })
71
+ end
72
+
73
+ # Proxy serialization — data is already serialized from HTTP
74
+ def serialize_session(data)
75
+ data
76
+ end
77
+
78
+ def serialize_annotation(data)
79
+ data
80
+ end
81
+
82
+ # Watch not supported via HTTP proxy — return empty after timeout
83
+ def subscribe(_session_id = nil, &_block)
84
+ nil
85
+ end
86
+
87
+ def unsubscribe(_sub)
88
+ nil
89
+ end
90
+
91
+ private
92
+
93
+ def get(path)
94
+ http = Net::HTTP.new(@uri.host, @uri.port)
95
+ http.open_timeout = 5
96
+ http.read_timeout = 10
97
+ req = Net::HTTP::Get.new(path)
98
+ resp = http.request(req)
99
+ return nil unless resp.is_a?(Net::HTTPSuccess)
100
+
101
+ JSON.parse(resp.body)
102
+ rescue StandardError => e
103
+ $stderr.puts "[rails-markup proxy] GET #{path} failed: #{e.message}"
104
+ nil
105
+ end
106
+
107
+ def post(path, body)
108
+ http = Net::HTTP.new(@uri.host, @uri.port)
109
+ http.open_timeout = 5
110
+ http.read_timeout = 10
111
+ req = Net::HTTP::Post.new(path, "Content-Type" => "application/json")
112
+ req.body = body.to_json
113
+ resp = http.request(req)
114
+ return nil unless resp.is_a?(Net::HTTPSuccess)
115
+
116
+ JSON.parse(resp.body)
117
+ rescue StandardError => e
118
+ $stderr.puts "[rails-markup proxy] POST #{path} failed: #{e.message}"
119
+ nil
120
+ end
121
+ end
122
+ end