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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +304 -0
- data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
- data/app/controllers/rails_markup/annotations_controller.rb +244 -0
- data/app/controllers/rails_markup/application_controller.rb +7 -0
- data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
- data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
- data/app/models/rails_markup/annotation.rb +176 -0
- data/app/views/layouts/rails_markup/application.html.erb +158 -0
- data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
- data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
- data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
- data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
- data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
- data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
- data/app/views/rails_markup/dashboard/board.html.erb +135 -0
- data/app/views/rails_markup/dashboard/index.html.erb +38 -0
- data/app/views/rails_markup/dashboard/show.html.erb +129 -0
- data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
- data/bin/rails-markup +5 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
- data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
- data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
- data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
- data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
- data/lib/generators/rails_markup/install_generator.rb +167 -0
- data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
- data/lib/rails_markup/cli/base.rb +8 -0
- data/lib/rails_markup/cli/initializer_writer.rb +54 -0
- data/lib/rails_markup/cli/setup_wizard.rb +229 -0
- data/lib/rails_markup/cli.rb +831 -0
- data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
- data/lib/rails_markup/configuration.rb +160 -0
- data/lib/rails_markup/engine.rb +18 -0
- data/lib/rails_markup/http_server.rb +226 -0
- data/lib/rails_markup/http_store_proxy.rb +122 -0
- data/lib/rails_markup/mcp_config.rb +258 -0
- data/lib/rails_markup/mcp_server.rb +639 -0
- data/lib/rails_markup/server.rb +73 -0
- data/lib/rails_markup/store.rb +219 -0
- data/lib/rails_markup/version.rb +5 -0
- data/lib/rails_markup.rb +11 -0
- data/lib/tasks/rails_markup_client_uuids.rake +19 -0
- 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
|