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,639 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+ require_relative "mcp_config"
9
+
10
+ module RailsMarkup
11
+ # MCP (Model Context Protocol) server speaking JSON-RPC 2.0 over stdio.
12
+ # Exposes five focused tools for AI agents to read and act on browser annotations.
13
+ # Each tool accepts an optional `environment` param ("development"|"production")
14
+ # to route to the correct backend (default: "development").
15
+ #
16
+ # Configuration (via .mcp.json env vars, set by `bin/markup configure`):
17
+ # RAILS_MARKUP_DEV_URL — local Rails server URL (auto-detected on install)
18
+ # RAILS_MARKUP_PROD_URL — production URL
19
+ # RAILS_MARKUP_PROD_TOKEN — production API token
20
+ # RAILS_MARKUP_MOUNT_PATH — engine mount path (default: /admin/annotations)
21
+ class McpServer
22
+ class ToolError < StandardError; end
23
+ class TargetError < ToolError; end
24
+
25
+ ENV_SCHEMA = {
26
+ environment: {
27
+ type: "string",
28
+ enum: %w[development production],
29
+ description: "Target environment (default: development)"
30
+ }
31
+ }.freeze
32
+
33
+ TOOLS = [
34
+ {
35
+ name: "rails_markup_read",
36
+ description: "Read pending annotations, sessions, one session, or one annotation without changing state.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ resource: {
41
+ type: "string",
42
+ enum: %w[pending sessions session annotation],
43
+ description: "Resource to read; session requires sessionId and annotation requires annotationId."
44
+ },
45
+ **ENV_SCHEMA,
46
+ sessionId: { type: "string", description: "Session ID, required only when resource is session; filters pending when supplied." },
47
+ annotationId: { type: "string", description: "Annotation ID, required only when resource is annotation." }
48
+ },
49
+ required: ["resource"],
50
+ additionalProperties: false
51
+ },
52
+ annotations: { readOnlyHint: true, destructiveHint: false }
53
+ },
54
+ {
55
+ name: "rails_markup_watch",
56
+ description: "In development, wait for newly created annotations and return a bounded batch without changing state.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ sessionId: { type: "string", description: "Optional session ID to filter" },
61
+ timeoutSeconds: { type: "number", description: "Max seconds to wait (default: 120, max: 300)" },
62
+ batchWindowSeconds: { type: "number", description: "Seconds to wait after first annotation before returning batch (default: 10, max: 60)" }
63
+ },
64
+ required: [],
65
+ additionalProperties: false
66
+ },
67
+ annotations: { readOnlyHint: true, destructiveHint: false }
68
+ },
69
+ {
70
+ name: "rails_markup_transition",
71
+ description: "Acknowledge or resolve one annotation; summary is used only when resolving.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ action: { type: "string", enum: %w[acknowledge resolve], description: "State transition to apply." },
76
+ annotationId: { type: "string", description: "The annotation ID" },
77
+ summary: { type: "string", description: "Optional resolution summary; valid only for resolve." },
78
+ **ENV_SCHEMA
79
+ },
80
+ required: %w[action annotationId],
81
+ additionalProperties: false
82
+ },
83
+ annotations: { readOnlyHint: false, destructiveHint: false }
84
+ },
85
+ {
86
+ name: "rails_markup_reply",
87
+ description: "Add a message to one annotation's discussion thread.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ annotationId: { type: "string", description: "The annotation ID" },
92
+ message: { type: "string", description: "Reply message" },
93
+ **ENV_SCHEMA
94
+ },
95
+ required: %w[annotationId message],
96
+ additionalProperties: false
97
+ },
98
+ annotations: { readOnlyHint: false, destructiveHint: false }
99
+ },
100
+ {
101
+ name: "rails_markup_dismiss",
102
+ description: "Destructively dismiss one annotation with an explicit reason.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ annotationId: { type: "string", description: "The annotation ID" },
107
+ reason: { type: "string", description: "Reason for dismissal" },
108
+ **ENV_SCHEMA
109
+ },
110
+ required: %w[annotationId reason],
111
+ additionalProperties: false
112
+ },
113
+ annotations: { readOnlyHint: false, destructiveHint: true }
114
+ }
115
+ ].freeze
116
+
117
+ TOOL_ARGUMENTS = {
118
+ "rails_markup_read" => %w[resource environment sessionId annotationId],
119
+ "rails_markup_watch" => %w[sessionId timeoutSeconds batchWindowSeconds],
120
+ "rails_markup_transition" => %w[action annotationId summary environment],
121
+ "rails_markup_reply" => %w[annotationId message environment],
122
+ "rails_markup_dismiss" => %w[annotationId reason environment]
123
+ }.freeze
124
+
125
+ # Legacy tool names → canonical handler + trusted injected args.
126
+ # Removed after v1.3.0.
127
+ LEGACY_ALIASES = {
128
+ "rails_markup_sessions" => { handler: "rails_markup_read", inject: { "resource" => "sessions" } },
129
+ "rails_markup_list_sessions" => { handler: "rails_markup_read", inject: { "resource" => "sessions" } },
130
+ "rails_markup_session" => { handler: "rails_markup_read", inject: { "resource" => "session" } },
131
+ "rails_markup_get_session" => { handler: "rails_markup_read", inject: { "resource" => "session" } },
132
+ "rails_markup_pending" => { handler: "rails_markup_read", inject: { "resource" => "pending" } },
133
+ "rails_markup_get_pending" => { handler: "rails_markup_read", inject: { "resource" => "pending" } },
134
+ "rails_markup_get_all_pending" => { handler: "rails_markup_read", inject: { "resource" => "pending" } },
135
+ "rails_markup_fetch_production" => {
136
+ handler: "rails_markup_read", inject: { "resource" => "pending", "environment" => "production" }
137
+ },
138
+ "rails_markup_watch_annotations" => { handler: "rails_markup_watch" },
139
+ "rails_markup_acknowledge" => { handler: "rails_markup_transition", inject: { "action" => "acknowledge" } },
140
+ "rails_markup_resolve" => { handler: "rails_markup_transition", inject: { "action" => "resolve" } },
141
+ "rails_markup_resolve_production" => {
142
+ handler: "rails_markup_transition", inject: { "action" => "resolve", "environment" => "production" }
143
+ },
144
+ "rails_markup_reply_production" => { handler: "rails_markup_reply", inject: { "environment" => "production" } },
145
+ "rails_markup_dismiss_production" => { handler: "rails_markup_dismiss", inject: { "environment" => "production" } }
146
+ }.freeze
147
+
148
+ def initialize(store:, input: $stdin, output: $stdout, dir: Dir.pwd)
149
+ @store = store
150
+ @input = input
151
+ @output = output
152
+ @mcp_config = McpConfig.new(dir: dir)
153
+ end
154
+
155
+ def start
156
+ @input.each_line do |line|
157
+ line = line.strip
158
+ next if line.empty?
159
+
160
+ request = JSON.parse(line)
161
+ response = handle_request(request)
162
+ write_response(response) if response
163
+ rescue JSON::ParserError
164
+ write_response(error_response(nil, -32700, "Parse error"))
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # ── Shared config ─────────────────────────────────────────
171
+ # ENV vars take precedence; fall back to .mcp.json values.
172
+
173
+ def config_value(env_key)
174
+ ENV[env_key] || @mcp_config.raw_env[env_key]
175
+ rescue JSON::ParserError
176
+ fail ToolError, "Rails Markup configuration is invalid."
177
+ end
178
+
179
+ def mount_path
180
+ config_value("RAILS_MARKUP_MOUNT_PATH") || "/admin/annotations"
181
+ end
182
+
183
+ def prod_url
184
+ config_value("RAILS_MARKUP_PROD_URL")
185
+ end
186
+
187
+ def prod_token
188
+ config_value("RAILS_MARKUP_PROD_TOKEN")
189
+ end
190
+
191
+ # Build the external API base for a given host URL.
192
+ # All annotation access goes through the engine's external controller:
193
+ # {base_url}{mount_path}/external/...
194
+ def external_api_base(base_url)
195
+ external_api_url(base_url)
196
+ end
197
+
198
+ def external_api_url(base_url, *segments)
199
+ uri = base_url.is_a?(URI::HTTP) ? base_url.dup : URI.parse(base_url)
200
+ base_parts = safe_path_parts(uri.path, label: "configured URL path")
201
+ mount_parts = safe_path_parts(mount_path, label: "configured mount path")
202
+ suffix = segments.map { |segment| safe_path_segment(segment) }
203
+ uri.path = "/#{(base_parts + mount_parts + ["external"] + suffix).join('/')}"
204
+ uri.to_s
205
+ rescue URI::InvalidURIError
206
+ fail TargetError, "Configured URL is invalid. Update Rails Markup configuration."
207
+ end
208
+
209
+ def validated_target_url(value, environment:)
210
+ uri = URI.parse(value.to_s)
211
+ if environment == "production" && !(uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty?)
212
+ fail TargetError, "Configured production URL must use HTTPS."
213
+ end
214
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
215
+ fail TargetError, "Configured #{environment} URL must use HTTP or HTTPS."
216
+ end
217
+ if uri.userinfo || uri.query || uri.fragment
218
+ fail TargetError, "Configured #{environment} URL cannot include credentials, query, or fragment."
219
+ end
220
+ safe_path_parts(uri.path, label: "configured URL path")
221
+
222
+ if environment == "development" && uri.scheme == "http" && !loopback_host?(uri.host)
223
+ fail TargetError, "Configured development HTTP URL must use a loopback host."
224
+ end
225
+
226
+ uri
227
+ rescue URI::InvalidURIError
228
+ fail TargetError, "Configured #{environment} URL is invalid."
229
+ end
230
+
231
+ def loopback_host?(host)
232
+ return true if host.casecmp?("localhost")
233
+
234
+ address = IPAddr.new(host)
235
+ address.loopback?
236
+ rescue IPAddr::InvalidAddressError
237
+ false
238
+ end
239
+
240
+ def safe_path_parts(path, label:)
241
+ path.to_s.split("/").reject(&:empty?).map do |part|
242
+ decoded = URI.decode_uri_component(part)
243
+ if %w[. ..].include?(decoded) || decoded.include?("/") || decoded.include?("\\")
244
+ fail TargetError, "The #{label} cannot contain traversal segments."
245
+ end
246
+ part
247
+ end
248
+ end
249
+
250
+ def safe_path_segment(segment)
251
+ value = segment.to_s
252
+ decoded = URI.decode_uri_component(value)
253
+ unless value.match?(/\A[A-Za-z0-9._~-]+\z/) && !%w[. ..].include?(decoded)
254
+ fail TargetError, "API path segment is invalid."
255
+ end
256
+ value
257
+ end
258
+
259
+ # ── JSON-RPC dispatch ─────────────────────────────────────
260
+
261
+ def handle_request(request)
262
+ unless request.is_a?(Hash) && request["jsonrpc"] == "2.0" && request["method"].is_a?(String)
263
+ return error_response(request.is_a?(Hash) ? request["id"] : nil, -32600, "Invalid Request")
264
+ end
265
+
266
+ id = request["id"]
267
+ method = request["method"]
268
+ params = request["params"] || {}
269
+ return error_response(id, -32602, "Invalid params") unless params.is_a?(Hash)
270
+
271
+ case method
272
+ when "initialize"
273
+ client_version = params["protocolVersion"]
274
+ supported = %w[2025-06-18 2025-03-26 2024-11-05]
275
+ negotiated = supported.include?(client_version) ? client_version : supported.first
276
+
277
+ result_response(id, {
278
+ protocolVersion: negotiated,
279
+ capabilities: { tools: {} },
280
+ serverInfo: { name: "rails-markup", title: "Rails Markup", version: RailsMarkup::VERSION }
281
+ })
282
+ when "notifications/initialized"
283
+ nil # No response for notifications
284
+ when "tools/list"
285
+ result_response(id, { tools: TOOLS })
286
+ when "tools/call"
287
+ arguments = params.key?("arguments") ? params["arguments"] : {}
288
+ handle_tool_call(id, params["name"], arguments)
289
+ when "ping"
290
+ result_response(id, {})
291
+ else
292
+ error_response(id, -32601, "Method not found")
293
+ end
294
+ end
295
+
296
+ def handle_tool_call(id, name, args)
297
+ return tool_error_response(id, "Tool arguments must be an object.") unless args.is_a?(Hash)
298
+
299
+ if (legacy = LEGACY_ALIASES[name])
300
+ allowed = TOOL_ARGUMENTS.fetch(legacy[:handler]) - legacy.fetch(:inject, {}).keys
301
+ return invalid_arguments_response(id, args.keys - allowed) unless (args.keys - allowed).empty?
302
+
303
+ $stderr.puts "[rails-markup] DEPRECATED: #{name} → use #{legacy[:handler]}"
304
+ name = legacy[:handler]
305
+ args = args.merge(legacy.fetch(:inject, {}))
306
+ end
307
+
308
+ return tool_error_response(id, "Unknown tool. Use tools/list for supported tools.") unless TOOL_ARGUMENTS.key?(name)
309
+
310
+ unknown = args.keys - TOOL_ARGUMENTS.fetch(name)
311
+ return invalid_arguments_response(id, unknown) unless unknown.empty?
312
+
313
+ if (message = validation_error(name, args))
314
+ return tool_error_response(id, message)
315
+ end
316
+
317
+ result = case name
318
+ when "rails_markup_read"
319
+ handle_read(args)
320
+ when "rails_markup_watch"
321
+ handle_watch(args)
322
+ when "rails_markup_transition"
323
+ if args["action"] == "acknowledge"
324
+ handle_action(args, "acknowledge")
325
+ else
326
+ handle_action(args, "resolve", summary: args["summary"])
327
+ end
328
+ when "rails_markup_dismiss"
329
+ handle_action(args, "dismiss", reason: args["reason"])
330
+ when "rails_markup_reply"
331
+ handle_action(args, "reply", message: args["message"])
332
+ end
333
+
334
+ content = [{ type: "text", text: result.to_json }]
335
+ result_response(id, { content: content })
336
+ rescue ToolError => e
337
+ tool_error_response(id, e.message)
338
+ rescue JSON::ParserError
339
+ tool_error_response(id, "Remote Rails Markup server returned invalid JSON.")
340
+ rescue URI::InvalidURIError
341
+ tool_error_response(id, "Configured Rails Markup URL is invalid.")
342
+ rescue Timeout::Error
343
+ tool_error_response(id, "Rails Markup request timed out.")
344
+ rescue SocketError, SystemCallError
345
+ tool_error_response(id, "Could not connect to the configured Rails Markup server.")
346
+ rescue OpenSSL::SSL::SSLError
347
+ tool_error_response(id, "Secure connection to the configured Rails Markup server failed.")
348
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, IOError
349
+ tool_error_response(id, "Rails Markup HTTP transport failed.")
350
+ rescue StandardError
351
+ tool_error_response(id, "Rails Markup tool request failed safely.")
352
+ end
353
+
354
+ def validation_error(name, args)
355
+ environment = args.key?("environment") ? args["environment"] : "development"
356
+ return "environment must be development or production." unless %w[development production].include?(environment)
357
+
358
+ case name
359
+ when "rails_markup_read"
360
+ resource = args["resource"]
361
+ return "resource must be pending, sessions, session, or annotation." unless %w[pending sessions session annotation].include?(resource)
362
+ return "sessionId must be a non-empty string." if args.key?("sessionId") && !nonempty_string?(args["sessionId"])
363
+ return "annotationId must be a non-empty string." if args.key?("annotationId") && !nonempty_string?(args["annotationId"])
364
+ return "sessionId is required when resource is session." if resource == "session" && blank?(args["sessionId"])
365
+ return "annotationId is required when resource is annotation." if resource == "annotation" && blank?(args["annotationId"])
366
+ return "annotationId is only valid when resource is annotation." if resource != "annotation" && args.key?("annotationId")
367
+ return "sessionId is only valid for pending or session resources." if !%w[pending session].include?(resource) && args.key?("sessionId")
368
+ if environment == "production" && %w[sessions session].include?(resource)
369
+ return "sessions and session reads are available only in development."
370
+ end
371
+ when "rails_markup_transition"
372
+ return "action must be acknowledge or resolve." unless %w[acknowledge resolve].include?(args["action"])
373
+ return "annotationId must be a non-empty string." unless nonempty_string?(args["annotationId"])
374
+ return "summary must be a string." if args.key?("summary") && !args["summary"].is_a?(String)
375
+ return "summary is only valid for resolve." if args["action"] != "resolve" && args.key?("summary")
376
+ when "rails_markup_reply"
377
+ return "annotationId and message must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["message"])
378
+ when "rails_markup_dismiss"
379
+ return "annotationId and reason must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["reason"])
380
+ when "rails_markup_watch"
381
+ return "sessionId must be a non-empty string." if args.key?("sessionId") && !nonempty_string?(args["sessionId"])
382
+ unless !args.key?("timeoutSeconds") || numeric_between?(args["timeoutSeconds"], 0, 300)
383
+ return "timeoutSeconds must be a number from 0 to 300."
384
+ end
385
+ unless !args.key?("batchWindowSeconds") || numeric_between?(args["batchWindowSeconds"], 0, 60)
386
+ return "batchWindowSeconds must be a number from 0 to 60."
387
+ end
388
+ end
389
+ end
390
+
391
+ def blank?(value)
392
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
393
+ end
394
+
395
+ def nonempty_string?(value)
396
+ value.is_a?(String) && !value.empty?
397
+ end
398
+
399
+ def numeric_between?(value, minimum, maximum)
400
+ value.is_a?(Numeric) && value.finite? && value.between?(minimum, maximum)
401
+ end
402
+
403
+ def invalid_arguments_response(id, _unknown)
404
+ tool_error_response(id, "Remove unsupported arguments.")
405
+ end
406
+
407
+ def tool_error_response(id, message)
408
+ result_response(id, {
409
+ content: [{ type: "text", text: { error: message }.to_json }],
410
+ isError: true
411
+ })
412
+ end
413
+
414
+ # ── Dev API proxy ─────────────────────────────────────────
415
+ # When RAILS_MARKUP_DEV_URL is set, local tools proxy to the engine's
416
+ # external API instead of the in-memory store. No token needed in dev.
417
+
418
+ def dev_url
419
+ config_value("RAILS_MARKUP_DEV_URL")
420
+ end
421
+
422
+ def handle_read(args)
423
+ return handle_fetch_production(args) if args["environment"] == "production" && args["resource"] == "pending"
424
+ if args["resource"] == "annotation" && (args["environment"] == "production" || dev_url)
425
+ return handle_remote_annotation(args)
426
+ end
427
+
428
+ case args["resource"]
429
+ when "sessions"
430
+ handle_local_or_proxy(:list_sessions, args) do
431
+ @store.list_sessions.map { |session| @store.serialize_session(session) }
432
+ end
433
+ when "session"
434
+ handle_local_or_proxy(:get_session, args) do
435
+ session = @store.get_session(args["sessionId"])
436
+ fail ToolError, "Session not found." unless session
437
+
438
+ @store.serialize_session(session)
439
+ end
440
+ when "annotation"
441
+ annotation = @store.get_annotation(args["annotationId"])
442
+ fail ToolError, "Annotation not found." unless annotation
443
+
444
+ @store.serialize_annotation(annotation)
445
+ when "pending"
446
+ action = args["sessionId"] ? :get_pending : :get_all_pending
447
+ handle_local_or_proxy(action, args) do
448
+ pending = if args["sessionId"]
449
+ @store.pending_for_session(args["sessionId"])
450
+ else
451
+ @store.all_pending
452
+ end
453
+ pending.map { |annotation| @store.serialize_annotation(annotation) }
454
+ end
455
+ end
456
+ end
457
+
458
+ def handle_action(args, action, **params)
459
+ return handle_production_action(args, action, **params) if args["environment"] == "production"
460
+
461
+ handle_local_or_proxy(action.to_sym, args) do
462
+ annotation = @store.public_send(action, args["annotationId"], **params)
463
+ fail ToolError, "Annotation not found." unless annotation
464
+
465
+ @store.serialize_annotation(annotation)
466
+ end
467
+ end
468
+
469
+ def handle_local_or_proxy(action, args, &fallback)
470
+ return fallback.call unless dev_url
471
+
472
+ target = validated_target_url(dev_url, environment: "development")
473
+
474
+ case action
475
+ when :list_sessions
476
+ []
477
+ when :get_session
478
+ fail ToolError, "Sessions are not available through the configured development API."
479
+ when :get_pending, :get_all_pending
480
+ dev_fetch_pending(target)
481
+ when :acknowledge
482
+ dev_action(target, args["annotationId"], "acknowledge")
483
+ when :resolve
484
+ dev_action(target, args["annotationId"], "resolve", summary: args["summary"])
485
+ when :dismiss
486
+ dev_action(target, args["annotationId"], "dismiss", reason: args["reason"])
487
+ when :reply
488
+ dev_action(target, args["annotationId"], "reply", message: args["message"])
489
+ else
490
+ fallback.call
491
+ end
492
+ end
493
+
494
+ def dev_fetch_pending(target)
495
+ resp = http_get(external_api_url(target, "pending"))
496
+ data = remote_json(resp)
497
+ { count: (data["annotations"] || []).size, annotations: data["annotations"] || [] }
498
+ end
499
+
500
+ def dev_action(target, annotation_id, action, **params)
501
+ return { error: "No annotationId provided." } unless annotation_id
502
+
503
+ resp = http_patch(external_api_url(target, annotation_id, action), params: params)
504
+ remote_json(resp)
505
+ end
506
+
507
+ # ── Production API ────────────────────────────────────────
508
+
509
+ def handle_fetch_production(args)
510
+ target, token = production_target
511
+ resp = http_get(external_api_url(target, "pending"), token: token)
512
+ data = remote_json(resp)
513
+ annotations = data["annotations"] || []
514
+
515
+ { count: annotations.size, annotations: annotations }
516
+ end
517
+
518
+ def handle_production_action(args, action, **params)
519
+ annotation_id = args["annotationId"]
520
+ return { error: "No annotationId provided." } unless annotation_id
521
+
522
+ target, token = production_target
523
+ resp = http_patch(external_api_url(target, annotation_id, action), token: token, params: params)
524
+ remote_json(resp)
525
+ end
526
+
527
+ def handle_remote_annotation(args)
528
+ if args["environment"] == "production"
529
+ target, token = production_target
530
+ else
531
+ target = validated_target_url(dev_url, environment: "development")
532
+ token = nil
533
+ end
534
+ resp = http_get(external_api_url(target, args["annotationId"]), token: token)
535
+ remote_json(resp)
536
+ end
537
+
538
+ def production_target
539
+ fail TargetError, "No production URL is configured. Run bin/markup configure." if blank?(prod_url)
540
+
541
+ target = validated_target_url(prod_url, environment: "production")
542
+ fail TargetError, "Production token is not configured. Run bin/markup configure." if blank?(prod_token)
543
+
544
+ [target, prod_token]
545
+ end
546
+
547
+ def remote_json(response)
548
+ unless response.is_a?(Net::HTTPSuccess)
549
+ code = response.code.to_i
550
+ if [401, 403].include?(code)
551
+ fail ToolError, "Authentication failed for the configured Rails Markup server."
552
+ end
553
+ fail ToolError, "Remote Rails Markup server returned HTTP #{code}."
554
+ end
555
+
556
+ JSON.parse(response.body)
557
+ rescue JSON::ParserError
558
+ fail ToolError, "Remote Rails Markup server returned invalid JSON."
559
+ end
560
+
561
+ # ── HTTP helpers ──────────────────────────────────────────
562
+
563
+ def http_get(url, token: nil)
564
+ uri = URI.parse(url)
565
+ req = Net::HTTP::Get.new(uri)
566
+ req["Authorization"] = "Bearer #{token}" if token
567
+ req["Accept"] = "application/json"
568
+ http = Net::HTTP.new(uri.host, uri.port)
569
+ http.use_ssl = uri.scheme == "https"
570
+ http.open_timeout = 10
571
+ http.read_timeout = 15
572
+ http.request(req)
573
+ end
574
+
575
+ def http_patch(url, token: nil, params: {})
576
+ uri = URI.parse(url)
577
+ req = Net::HTTP::Patch.new(uri)
578
+ req["Authorization"] = "Bearer #{token}" if token
579
+ req["Content-Type"] = "application/json"
580
+ req["Accept"] = "application/json"
581
+ req.body = params.to_json unless params.empty?
582
+ http = Net::HTTP.new(uri.host, uri.port)
583
+ http.use_ssl = uri.scheme == "https"
584
+ http.open_timeout = 10
585
+ http.read_timeout = 15
586
+ http.request(req)
587
+ end
588
+
589
+ # ── Watch mode ────────────────────────────────────────────
590
+
591
+ def handle_watch(args)
592
+ sub = nil
593
+ timeout = [args["timeoutSeconds"]&.to_i || 120, 300].min
594
+ batch_window = [args["batchWindowSeconds"]&.to_i || 10, 60].min
595
+ session_id = args["sessionId"]
596
+
597
+ batch = []
598
+ first_received = false
599
+ batch_deadline = nil
600
+
601
+ sub = @store.subscribe(session_id) do |data|
602
+ next unless data[:type] == "annotation_created"
603
+
604
+ batch << data[:annotation]
605
+ unless first_received
606
+ first_received = true
607
+ batch_deadline = Time.now + batch_window
608
+ end
609
+ end
610
+
611
+ deadline = Time.now + timeout
612
+ loop do
613
+ break if Time.now >= deadline
614
+ break if first_received && Time.now >= batch_deadline
615
+
616
+ sleep 0.5
617
+ end
618
+
619
+ batch
620
+ ensure
621
+ @store.unsubscribe(sub) unless sub.nil?
622
+ end
623
+
624
+ # ── JSON-RPC responses ────────────────────────────────────
625
+
626
+ def result_response(id, result)
627
+ { jsonrpc: "2.0", id: id, result: result }
628
+ end
629
+
630
+ def error_response(id, code, message)
631
+ { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
632
+ end
633
+
634
+ def write_response(response)
635
+ @output.puts(response.to_json)
636
+ @output.flush
637
+ end
638
+ end
639
+ end