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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +337 -0
- data/Rakefile +20 -0
- data/SPEC.md +532 -0
- data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
- data/app/assets/stylesheets/context_drop/application.css +2 -0
- data/app/controllers/context_drop/application_controller.rb +114 -0
- data/app/controllers/context_drop/attachments_controller.rb +31 -0
- data/app/controllers/context_drop/configs_controller.rb +20 -0
- data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
- data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
- data/app/controllers/context_drop/health_controller.rb +9 -0
- data/app/controllers/context_drop/mcp_controller.rb +67 -0
- data/app/helpers/context_drop/widget_helper.rb +62 -0
- data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
- data/app/javascript/context_drop/api-client.ts +87 -0
- data/app/javascript/context_drop/element-picker.ts +1004 -0
- data/app/javascript/context_drop/icons.ts +35 -0
- data/app/javascript/context_drop/index.ts +11 -0
- data/app/javascript/context_drop/sanitizer.ts +454 -0
- data/app/javascript/context_drop/screenshot.ts +214 -0
- data/app/javascript/context_drop/styles.ts +690 -0
- data/app/javascript/context_drop/types.ts +110 -0
- data/app/javascript/context_drop/widget.ts +1120 -0
- data/app/models/context_drop/application_record.rb +7 -0
- data/app/models/context_drop/attachment.rb +30 -0
- data/app/models/context_drop/element_reference.rb +47 -0
- data/app/models/context_drop/feedback.rb +94 -0
- data/app/services/context_drop/metadata_sanitizer.rb +215 -0
- data/app/services/context_drop/upload_validator.rb +217 -0
- data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
- data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
- data/app/views/layouts/context_drop/application.html.erb +215 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
- data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
- data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
- data/lib/context_drop/base64_util.rb +15 -0
- data/lib/context_drop/configuration.rb +155 -0
- data/lib/context_drop/engine.rb +32 -0
- data/lib/context_drop/mcp/dispatcher.rb +197 -0
- data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
- data/lib/context_drop/mcp/server.rb +25 -0
- data/lib/context_drop/mcp/stdio_runner.rb +62 -0
- data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
- data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
- data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
- data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
- data/lib/context_drop/version.rb +5 -0
- data/lib/context_drop.rb +32 -0
- data/lib/generators/context_drop/install/install_generator.rb +52 -0
- data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
- data/lib/tasks/context_drop_tasks.rake +14 -0
- 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
|