context_drop 0.1.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ContextDrop
6
+ module MCP
7
+ class Dispatcher
8
+ PROTOCOL_VERSION = "2025-03-26"
9
+
10
+ TOOLS = {
11
+ "list_feedbacks" => ContextDrop::MCP::Tools::ListFeedbacks,
12
+ "get_feedback" => ContextDrop::MCP::Tools::GetFeedback,
13
+ "get_feedback_attachment" => ContextDrop::MCP::Tools::GetFeedbackAttachment,
14
+ "update_feedback_status" => ContextDrop::MCP::Tools::UpdateFeedbackStatus
15
+ }.freeze
16
+
17
+ PROMPTS = {
18
+ "investigate_feedback" => ContextDrop::MCP::Prompts::InvestigateFeedback
19
+ }.freeze
20
+
21
+ RESOURCES = [
22
+ {
23
+ uri: "contextdrop://feedbacks/new",
24
+ name: "Unresolved New Feedbacks",
25
+ description: "List of all ContextDrop feedback records currently in 'new' status.",
26
+ mimeType: "application/json"
27
+ }
28
+ ].freeze
29
+
30
+ RESOURCE_TEMPLATES = [
31
+ {
32
+ uriTemplate: "contextdrop://feedbacks/{id}",
33
+ name: "Feedback Record by ID",
34
+ description: "Full ContextDrop feedback submission, DOM element snapshots, and attachment metadata for a specific ID.",
35
+ mimeType: "application/json"
36
+ }
37
+ ].freeze
38
+
39
+ class << self
40
+ def call(raw_message)
41
+ if raw_message.is_a?(Array)
42
+ responses = raw_message.filter_map { |item| dispatch_single(item) }
43
+ return responses.empty? ? nil : responses
44
+ end
45
+
46
+ dispatch_single(raw_message)
47
+ end
48
+
49
+ private
50
+
51
+ def dispatch_single(msg)
52
+ unless msg.is_a?(Hash)
53
+ return jsonrpc_error(nil, -32_600, "Invalid Request: expected JSON object")
54
+ end
55
+
56
+ req = msg.transform_keys(&:to_s)
57
+ id = req["id"]
58
+ method = req["method"].to_s
59
+ params = req["params"].is_a?(Hash) ? req["params"].transform_keys(&:to_s) : {}
60
+
61
+ # Notifications have no `id` and expect no response
62
+ return nil if method.start_with?("notifications/") || (id.nil? && !req.key?("id"))
63
+
64
+ case method
65
+ when "initialize"
66
+ client_version = params["protocolVersion"].to_s.strip
67
+ negotiated_version = client_version.empty? ? PROTOCOL_VERSION : client_version
68
+ jsonrpc_result(id, {
69
+ protocolVersion: negotiated_version,
70
+ capabilities: {
71
+ tools: { listChanged: false },
72
+ resources: { subscribe: false, listChanged: false },
73
+ prompts: { listChanged: false }
74
+ },
75
+ serverInfo: {
76
+ name: "context_drop",
77
+ version: ContextDrop::VERSION
78
+ }
79
+ })
80
+ when "ping"
81
+ jsonrpc_result(id, {})
82
+ when "tools/list"
83
+ jsonrpc_result(id, {
84
+ tools: TOOLS.values.map { |tool_cls| tool_cls::DEFINITION }
85
+ })
86
+ when "tools/call"
87
+ handle_tool_call(id, params)
88
+ when "resources/list"
89
+ jsonrpc_result(id, { resources: RESOURCES })
90
+ when "resources/templates/list"
91
+ jsonrpc_result(id, { resourceTemplates: RESOURCE_TEMPLATES })
92
+ when "resources/read"
93
+ handle_resource_read(id, params)
94
+ when "prompts/list"
95
+ jsonrpc_result(id, {
96
+ prompts: PROMPTS.values.map { |prompt_cls| prompt_cls::DEFINITION }
97
+ })
98
+ when "prompts/get"
99
+ handle_prompt_get(id, params)
100
+ else
101
+ jsonrpc_error(id, -32_601, "Method not found: #{method}")
102
+ end
103
+ rescue StandardError => e
104
+ jsonrpc_error(id, -32_603, "Internal error: #{e.message}")
105
+ end
106
+
107
+ def handle_tool_call(id, params)
108
+ tool_name = params["name"].to_s
109
+ tool_cls = TOOLS[tool_name]
110
+ unless tool_cls
111
+ return jsonrpc_error(id, -32_602, "Unknown tool: #{tool_name}")
112
+ end
113
+
114
+ arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
115
+ result = tool_cls.call(arguments)
116
+ jsonrpc_result(id, result)
117
+ end
118
+
119
+ def handle_resource_read(id, params)
120
+ uri = params["uri"].to_s.strip
121
+ case uri
122
+ when "contextdrop://feedbacks/new"
123
+ records = ContextDrop::Feedback
124
+ .includes(:submitter, :element_references, :attachments)
125
+ .new_records
126
+ .recent
127
+ .limit(50)
128
+ .map { |fb| ContextDrop::MCP::Tools::ListFeedbacks.serialize_summary(fb) }
129
+ jsonrpc_result(id, {
130
+ contents: [
131
+ {
132
+ uri: uri,
133
+ mimeType: "application/json",
134
+ text: JSON.pretty_generate(feedbacks: records)
135
+ }
136
+ ]
137
+ })
138
+ when %r{\Acontextdrop://feedbacks/(\d+)\z}
139
+ feedback_id = Regexp.last_match(1)
140
+ feedback = ContextDrop::Feedback
141
+ .includes(:submitter, :element_references, :attachments)
142
+ .find_by(id: feedback_id)
143
+ unless feedback
144
+ return jsonrpc_error(id, -32_602, "Feedback ##{feedback_id} not found")
145
+ end
146
+
147
+ payload = ContextDrop::MCP::Tools::GetFeedback.serialize_feedback(feedback)
148
+ jsonrpc_result(id, {
149
+ contents: [
150
+ {
151
+ uri: uri,
152
+ mimeType: "application/json",
153
+ text: JSON.pretty_generate(payload)
154
+ }
155
+ ]
156
+ })
157
+ else
158
+ jsonrpc_error(id, -32_602, "Unknown resource URI: #{uri}")
159
+ end
160
+ end
161
+
162
+ def handle_prompt_get(id, params)
163
+ prompt_name = params["name"].to_s
164
+ prompt_cls = PROMPTS[prompt_name]
165
+ unless prompt_cls
166
+ return jsonrpc_error(id, -32_602, "Unknown prompt: #{prompt_name}")
167
+ end
168
+
169
+ arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
170
+ result = prompt_cls.call(arguments)
171
+ jsonrpc_result(id, result)
172
+ rescue ContextDrop::Error => e
173
+ jsonrpc_error(id, -32_602, e.message)
174
+ end
175
+
176
+ def jsonrpc_result(id, result)
177
+ {
178
+ jsonrpc: "2.0",
179
+ id: id,
180
+ result: result
181
+ }
182
+ end
183
+
184
+ def jsonrpc_error(id, code, message)
185
+ {
186
+ jsonrpc: "2.0",
187
+ id: id,
188
+ error: {
189
+ code: code,
190
+ message: message
191
+ }
192
+ }
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ module MCP
5
+ module Prompts
6
+ class InvestigateFeedback
7
+ DEFINITION = {
8
+ name: "investigate_feedback",
9
+ description: "Generate a structured investigation brief for a ContextDrop feedback record, including its originating URL, DOM element selectors/paths, and visual screenshots.",
10
+ arguments: [
11
+ {
12
+ name: "feedback_id",
13
+ description: "ID of the ContextDrop::Feedback record to investigate.",
14
+ required: true
15
+ }
16
+ ]
17
+ }.freeze
18
+
19
+ def self.call(args = {})
20
+ params = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
21
+ feedback_id = params["feedback_id"] || params["id"]
22
+ raise ContextDrop::Error, "Missing required argument: feedback_id" if feedback_id.nil? || feedback_id.to_s.strip.empty?
23
+
24
+ feedback = ContextDrop::Feedback
25
+ .includes(:submitter, :element_references, :attachments)
26
+ .find_by(id: feedback_id)
27
+ raise ContextDrop::Error, "Feedback ##{feedback_id} not found" unless feedback
28
+
29
+ element_lines = if feedback.element_references.any?
30
+ feedback.element_references.map.with_index(1) do |el, idx|
31
+ <<~EL.strip
32
+ #{idx}. `<#{el.tag_name}>` — Selector: `#{el.selector}`
33
+ - DOM Path: `#{el.dom_path || 'N/A'}`
34
+ - Visible Text: #{el.text_content.inspect}
35
+ - Bounding Box: `#{JSON.generate(el.bounding_rect || {})}`
36
+ - Attributes: `#{JSON.generate(el.attributes_snapshot || {})}`
37
+ EL
38
+ end.join("\n")
39
+ else
40
+ "None selected."
41
+ end
42
+
43
+ instructions = <<~PROMPT
44
+ Investigate and resolve ContextDrop Feedback ##{feedback.id} (status: #{feedback.status}).
45
+
46
+ ## Reported Feedback
47
+ - **Message:** #{feedback.message.presence || '(No text message provided)'}
48
+ - **Originating Page URL:** `#{feedback.page_url}`
49
+ - **Submitter:** #{feedback.submitter_label}
50
+ - **Submitted At:** #{feedback.created_at&.iso8601}
51
+ - **Viewport / Metadata:** `#{JSON.generate(feedback.metadata || {})}`
52
+
53
+ ## Attached DOM Element References
54
+ #{element_lines}
55
+
56
+ ## Recommended Investigation Steps
57
+ 1. Locate the Rails route and controller action corresponding to `#{feedback.page_url}` in `config/routes.rb` and `app/controllers/`.
58
+ 2. Search the view templates (`app/views/`, ViewComponents, or frontend components) for the attached DOM selectors (`#{feedback.element_references.map(&:selector).join(', ')}`) and structural DOM paths (`#{feedback.element_references.map(&:dom_path).compact.join(', ')}`).
59
+ 3. Inspect any attached screenshots (`get_feedback` with `id: #{feedback.id}`) to verify the visual layout issue.
60
+ 4. Propose and verify a fix, then call `update_feedback_status` (`id: #{feedback.id}`, `status: "resolved"`) once complete.
61
+ PROMPT
62
+
63
+ message_content = [{ type: "text", text: instructions }]
64
+
65
+ feedback.attachments.each do |att|
66
+ img_block = ContextDrop::MCP::Tools::GetFeedback.build_image_content_block(att)
67
+ message_content << img_block if img_block
68
+ end
69
+
70
+ {
71
+ description: "Investigation brief for ContextDrop Feedback ##{feedback.id} on #{feedback.page_url}",
72
+ messages: [
73
+ {
74
+ role: "user",
75
+ content: message_content.size == 1 ? message_content.first : message_content
76
+ }
77
+ ]
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/list_feedbacks"
4
+ require_relative "tools/get_feedback"
5
+ require_relative "tools/get_feedback_attachment"
6
+ require_relative "tools/update_feedback_status"
7
+ require_relative "prompts/investigate_feedback"
8
+ require_relative "dispatcher"
9
+ require_relative "stdio_runner"
10
+
11
+ module ContextDrop
12
+ module MCP
13
+ class Server
14
+ class << self
15
+ def handle_http_payload(payload)
16
+ Dispatcher.call(payload)
17
+ end
18
+
19
+ def run_stdio(input: $stdin, output: $stdout, error: $stderr)
20
+ StdioRunner.new(input: input, output: output, error: error).run
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ begin
5
+ require "logger"
6
+ rescue LoadError
7
+ # Logger gem is optional outside full Rails environment
8
+ end
9
+
10
+ module ContextDrop
11
+ module MCP
12
+ class StdioRunner
13
+ def initialize(input: $stdin, output: $stdout, error: $stderr)
14
+ @input = input
15
+ @output = output
16
+ @error = error
17
+ end
18
+
19
+ def run
20
+ isolate_stdio_logging!
21
+ @output.sync = true if @output.respond_to?(:sync=)
22
+
23
+ while (line = @input.gets)
24
+ stripped = line.strip
25
+ next if stripped.empty?
26
+
27
+ response = process_line(stripped)
28
+ next if response.nil?
29
+
30
+ @output.puts(JSON.generate(response))
31
+ @output.flush if @output.respond_to?(:flush)
32
+ end
33
+ end
34
+
35
+ def process_line(line)
36
+ parsed = JSON.parse(line)
37
+ ContextDrop::MCP::Dispatcher.call(parsed)
38
+ rescue JSON::ParserError => e
39
+ {
40
+ jsonrpc: "2.0",
41
+ id: nil,
42
+ error: {
43
+ code: -32_700,
44
+ message: "Parse error: #{e.message}"
45
+ }
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def isolate_stdio_logging!
52
+ return unless defined?(Logger)
53
+
54
+ stderr_logger = Logger.new(@error)
55
+ stderr_logger.level = ENV["CONTEXT_DROP_MCP_DEBUG"] == "1" ? Logger::DEBUG : Logger::WARN
56
+
57
+ Rails.logger = stderr_logger if defined?(Rails) && Rails.respond_to?(:logger=)
58
+ ActiveRecord::Base.logger = stderr_logger if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:logger=)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ module MCP
5
+ module Tools
6
+ class GetFeedback
7
+ MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
8
+
9
+ DEFINITION = {
10
+ name: "get_feedback",
11
+ description: "Retrieve a complete ContextDrop feedback record, all DOM ElementReference snapshots, and screenshots/images as multimodal MCP image blocks.",
12
+ inputSchema: {
13
+ type: "object",
14
+ required: ["id"],
15
+ properties: {
16
+ id: {
17
+ type: %w[integer string],
18
+ description: "The ID of the ContextDrop::Feedback record."
19
+ },
20
+ include_images: {
21
+ type: "boolean",
22
+ description: "When true (default), includes attached screenshots and images as base64 MCP image content blocks."
23
+ }
24
+ }
25
+ }
26
+ }.freeze
27
+
28
+ def self.call(args = {})
29
+ params = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
30
+ feedback_id = params["id"] || params["feedback_id"]
31
+ return error_result("Missing required parameter: id") if feedback_id.nil? || feedback_id.to_s.strip.empty?
32
+
33
+ include_images = params.key?("include_images") ? !!params["include_images"] : true
34
+
35
+ feedback = ContextDrop::Feedback
36
+ .includes(:submitter, :element_references, :attachments)
37
+ .find_by(id: feedback_id)
38
+
39
+ return error_result("Feedback ##{feedback_id} not found") unless feedback
40
+
41
+ payload = serialize_feedback(feedback)
42
+ content_blocks = [
43
+ {
44
+ type: "text",
45
+ text: JSON.pretty_generate(payload)
46
+ }
47
+ ]
48
+
49
+ if include_images
50
+ feedback.attachments.each do |att|
51
+ img_block = build_image_content_block(att)
52
+ content_blocks << img_block if img_block
53
+ end
54
+ end
55
+
56
+ {
57
+ content: content_blocks,
58
+ isError: false
59
+ }
60
+ end
61
+
62
+ def self.serialize_feedback(feedback)
63
+ {
64
+ id: feedback.id,
65
+ status: feedback.status,
66
+ message: feedback.message,
67
+ page_url: feedback.page_url,
68
+ user_agent: feedback.user_agent,
69
+ submitter: {
70
+ type: feedback.submitter_type,
71
+ id: feedback.submitter_id,
72
+ label: feedback.submitter_label
73
+ },
74
+ metadata: feedback.metadata || {},
75
+ created_at: feedback.created_at&.iso8601,
76
+ updated_at: feedback.updated_at&.iso8601,
77
+ element_references: feedback.element_references.map do |el|
78
+ {
79
+ id: el.id,
80
+ client_ref: el.client_ref,
81
+ tag_name: el.tag_name,
82
+ selector: el.selector,
83
+ dom_path: el.dom_path,
84
+ text_content: el.text_content,
85
+ attributes_snapshot: el.attributes_snapshot || {},
86
+ bounding_rect: el.bounding_rect || {},
87
+ viewport: el.viewport || {},
88
+ metadata: el.metadata || {},
89
+ captured_at: el.captured_at&.iso8601
90
+ }
91
+ end,
92
+ attachments: feedback.attachments.map do |att|
93
+ attached = att.respond_to?(:file) && att.file.attached?
94
+ {
95
+ id: att.id,
96
+ attachment_type: att.attachment_type,
97
+ element_reference_id: att.element_reference_id,
98
+ metadata: att.metadata || {},
99
+ filename: attached ? att.file.filename.to_s : nil,
100
+ content_type: attached ? att.file.content_type : nil,
101
+ byte_size: attached ? att.file.byte_size : nil,
102
+ created_at: att.created_at&.iso8601
103
+ }
104
+ end
105
+ }
106
+ end
107
+
108
+ def self.build_image_content_block(attachment)
109
+ return nil unless attachment.respond_to?(:file) && attachment.file.attached?
110
+ return nil if attachment.file.byte_size.to_i > MAX_INLINE_IMAGE_BYTES
111
+
112
+ raw_bytes = attachment.file.download
113
+ return nil if raw_bytes.nil? || raw_bytes.empty?
114
+
115
+ {
116
+ type: "image",
117
+ data: ContextDrop::Base64Util.strict_encode64(raw_bytes),
118
+ mimeType: attachment.file.content_type || "image/png"
119
+ }
120
+ rescue StandardError
121
+ nil
122
+ end
123
+
124
+ def self.error_result(msg)
125
+ {
126
+ content: [{ type: "text", text: JSON.generate(error: msg) }],
127
+ isError: true
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ module MCP
5
+ module Tools
6
+ class GetFeedbackAttachment
7
+ DEFINITION = {
8
+ name: "get_feedback_attachment",
9
+ description: "Retrieve a specific ContextDrop::Attachment record and its binary image/screenshot as an MCP image content block.",
10
+ inputSchema: {
11
+ type: "object",
12
+ required: ["attachment_id"],
13
+ properties: {
14
+ attachment_id: {
15
+ type: %w[integer string],
16
+ description: "The ID of the ContextDrop::Attachment record."
17
+ }
18
+ }
19
+ }
20
+ }.freeze
21
+
22
+ def self.call(args = {})
23
+ params = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
24
+ att_id = params["attachment_id"] || params["id"]
25
+ return error_result("Missing required parameter: attachment_id") if att_id.nil? || att_id.to_s.strip.empty?
26
+
27
+ attachment = ContextDrop::Attachment.includes(:element_reference).find_by(id: att_id)
28
+ return error_result("Attachment ##{att_id} not found") unless attachment
29
+
30
+ attached = attachment.respond_to?(:file) && attachment.file.attached?
31
+ meta_payload = {
32
+ id: attachment.id,
33
+ feedback_id: attachment.feedback_id,
34
+ element_reference_id: attachment.element_reference_id,
35
+ element_selector: attachment.element_reference&.selector,
36
+ attachment_type: attachment.attachment_type,
37
+ metadata: attachment.metadata || {},
38
+ filename: attached ? attachment.file.filename.to_s : nil,
39
+ content_type: attached ? attachment.file.content_type : nil,
40
+ byte_size: attached ? attachment.file.byte_size : nil
41
+ }
42
+
43
+ blocks = [{ type: "text", text: JSON.pretty_generate(meta_payload) }]
44
+
45
+ if attached
46
+ raw_bytes = attachment.file.download
47
+ if raw_bytes && !raw_bytes.empty?
48
+ blocks << {
49
+ type: "image",
50
+ data: ContextDrop::Base64Util.strict_encode64(raw_bytes),
51
+ mimeType: attachment.file.content_type || "image/png"
52
+ }
53
+ end
54
+ end
55
+
56
+ {
57
+ content: blocks,
58
+ isError: false
59
+ }
60
+ end
61
+
62
+ def self.error_result(msg)
63
+ {
64
+ content: [{ type: "text", text: JSON.generate(error: msg) }],
65
+ isError: true
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end