crucible 0.1.2
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/.rspec +3 -0
- data/.rubocop.yml +102 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +366 -0
- data/Rakefile +23 -0
- data/TESTING.md +319 -0
- data/config.sample.yml +48 -0
- data/crucible.gemspec +48 -0
- data/exe/crucible +122 -0
- data/lib/crucible/configuration.rb +212 -0
- data/lib/crucible/server.rb +123 -0
- data/lib/crucible/session_manager.rb +209 -0
- data/lib/crucible/stealth/evasions/chrome_app.js +75 -0
- data/lib/crucible/stealth/evasions/chrome_csi.js +33 -0
- data/lib/crucible/stealth/evasions/chrome_load_times.js +44 -0
- data/lib/crucible/stealth/evasions/chrome_runtime.js +190 -0
- data/lib/crucible/stealth/evasions/iframe_content_window.js +101 -0
- data/lib/crucible/stealth/evasions/media_codecs.js +65 -0
- data/lib/crucible/stealth/evasions/navigator_hardware_concurrency.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_languages.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_permissions.js +53 -0
- data/lib/crucible/stealth/evasions/navigator_plugins.js +261 -0
- data/lib/crucible/stealth/evasions/navigator_vendor.js +18 -0
- data/lib/crucible/stealth/evasions/navigator_webdriver.js +16 -0
- data/lib/crucible/stealth/evasions/webgl_vendor.js +43 -0
- data/lib/crucible/stealth/evasions/window_outerdimensions.js +18 -0
- data/lib/crucible/stealth/utils.js +266 -0
- data/lib/crucible/stealth.rb +213 -0
- data/lib/crucible/tools/cookies.rb +206 -0
- data/lib/crucible/tools/downloads.rb +273 -0
- data/lib/crucible/tools/extraction.rb +335 -0
- data/lib/crucible/tools/helpers.rb +46 -0
- data/lib/crucible/tools/interaction.rb +355 -0
- data/lib/crucible/tools/navigation.rb +181 -0
- data/lib/crucible/tools/sessions.rb +85 -0
- data/lib/crucible/tools/stealth.rb +167 -0
- data/lib/crucible/tools.rb +42 -0
- data/lib/crucible/version.rb +5 -0
- data/lib/crucible.rb +60 -0
- metadata +201 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Crucible
|
|
7
|
+
module Tools
|
|
8
|
+
# Cookie management tools: get_cookies, set_cookies, clear_cookies
|
|
9
|
+
module Cookies
|
|
10
|
+
class << self
|
|
11
|
+
def tools(sessions, _config)
|
|
12
|
+
[
|
|
13
|
+
get_cookies_tool(sessions),
|
|
14
|
+
set_cookies_tool(sessions),
|
|
15
|
+
clear_cookies_tool(sessions)
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def get_cookies_tool(sessions)
|
|
22
|
+
MCP::Tool.define(
|
|
23
|
+
name: 'get_cookies',
|
|
24
|
+
description: 'Get cookies from the current page',
|
|
25
|
+
input_schema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
session: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Session name',
|
|
31
|
+
default: 'default'
|
|
32
|
+
},
|
|
33
|
+
name: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Specific cookie name to get (optional, returns all if not specified)'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: []
|
|
39
|
+
}
|
|
40
|
+
) do |session: 'default', name: nil, **|
|
|
41
|
+
page = sessions.page(session)
|
|
42
|
+
|
|
43
|
+
# Helper to convert cookie to hash
|
|
44
|
+
to_hash = lambda do |c|
|
|
45
|
+
{
|
|
46
|
+
name: c.name,
|
|
47
|
+
value: c.value,
|
|
48
|
+
domain: c.domain,
|
|
49
|
+
path: c.path,
|
|
50
|
+
secure: c.secure?,
|
|
51
|
+
httpOnly: c.httponly?,
|
|
52
|
+
sameSite: c.samesite,
|
|
53
|
+
expires: c.expires
|
|
54
|
+
}.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
cookies = if name
|
|
58
|
+
cookie = page.cookies[name]
|
|
59
|
+
cookie ? [to_hash.call(cookie)] : []
|
|
60
|
+
else
|
|
61
|
+
page.cookies.all.values.map { |c| to_hash.call(c) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(cookies) }])
|
|
65
|
+
rescue Ferrum::Error => e
|
|
66
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Get cookies failed: #{e.message}" }], error: true)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set_cookies_tool(sessions)
|
|
71
|
+
MCP::Tool.define(
|
|
72
|
+
name: 'set_cookies',
|
|
73
|
+
description: 'Set one or more cookies',
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
session: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'Session name',
|
|
80
|
+
default: 'default'
|
|
81
|
+
},
|
|
82
|
+
cookies: {
|
|
83
|
+
type: 'array',
|
|
84
|
+
description: 'Array of cookies to set',
|
|
85
|
+
items: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
name: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Cookie name'
|
|
91
|
+
},
|
|
92
|
+
value: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'Cookie value'
|
|
95
|
+
},
|
|
96
|
+
domain: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'Cookie domain'
|
|
99
|
+
},
|
|
100
|
+
path: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
description: 'Cookie path',
|
|
103
|
+
default: '/'
|
|
104
|
+
},
|
|
105
|
+
secure: {
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
description: 'Secure cookie flag',
|
|
108
|
+
default: false
|
|
109
|
+
},
|
|
110
|
+
httpOnly: {
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
description: 'HttpOnly cookie flag',
|
|
113
|
+
default: false
|
|
114
|
+
},
|
|
115
|
+
sameSite: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'SameSite attribute',
|
|
118
|
+
enum: %w[Strict Lax None]
|
|
119
|
+
},
|
|
120
|
+
expires: {
|
|
121
|
+
type: 'integer',
|
|
122
|
+
description: 'Expiration timestamp (Unix epoch)'
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
required: %w[name value]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
required: ['cookies']
|
|
130
|
+
}
|
|
131
|
+
) do |cookies:, session: 'default', **|
|
|
132
|
+
page = sessions.page(session)
|
|
133
|
+
|
|
134
|
+
cookies.each do |cookie|
|
|
135
|
+
cookie_opts = {
|
|
136
|
+
name: cookie[:name] || cookie['name'],
|
|
137
|
+
value: cookie[:value] || cookie['value']
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Add optional fields if present
|
|
141
|
+
domain = cookie[:domain] || cookie['domain']
|
|
142
|
+
cookie_opts[:domain] = domain if domain
|
|
143
|
+
|
|
144
|
+
path = cookie[:path] || cookie['path']
|
|
145
|
+
cookie_opts[:path] = path if path
|
|
146
|
+
|
|
147
|
+
secure = cookie[:secure] || cookie['secure']
|
|
148
|
+
cookie_opts[:secure] = secure unless secure.nil?
|
|
149
|
+
|
|
150
|
+
http_only = cookie[:httpOnly] || cookie['httpOnly']
|
|
151
|
+
cookie_opts[:httponly] = http_only unless http_only.nil?
|
|
152
|
+
|
|
153
|
+
same_site = cookie[:sameSite] || cookie['sameSite']
|
|
154
|
+
cookie_opts[:samesite] = same_site if same_site
|
|
155
|
+
|
|
156
|
+
expires = cookie[:expires] || cookie['expires']
|
|
157
|
+
cookie_opts[:expires] = expires if expires
|
|
158
|
+
|
|
159
|
+
page.cookies.set(**cookie_opts)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Set #{cookies.size} cookie(s)" }])
|
|
163
|
+
rescue Ferrum::Error => e
|
|
164
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Set cookies failed: #{e.message}" }], error: true)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def clear_cookies_tool(sessions)
|
|
169
|
+
MCP::Tool.define(
|
|
170
|
+
name: 'clear_cookies',
|
|
171
|
+
description: 'Clear all cookies or a specific cookie',
|
|
172
|
+
input_schema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
session: {
|
|
176
|
+
type: 'string',
|
|
177
|
+
description: 'Session name',
|
|
178
|
+
default: 'default'
|
|
179
|
+
},
|
|
180
|
+
name: {
|
|
181
|
+
type: 'string',
|
|
182
|
+
description: 'Specific cookie name to clear (optional, clears all if not specified)'
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
required: []
|
|
186
|
+
}
|
|
187
|
+
) do |session: 'default', name: nil, **|
|
|
188
|
+
page = sessions.page(session)
|
|
189
|
+
|
|
190
|
+
if name
|
|
191
|
+
# Ferrum requires domain or url to remove a specific cookie
|
|
192
|
+
url = page.current_url
|
|
193
|
+
page.cookies.remove(name: name, url: url)
|
|
194
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Cleared cookie: #{name}" }])
|
|
195
|
+
else
|
|
196
|
+
page.cookies.clear
|
|
197
|
+
MCP::Tool::Response.new([{ type: 'text', text: 'Cleared all cookies' }])
|
|
198
|
+
end
|
|
199
|
+
rescue Ferrum::Error => e
|
|
200
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Clear cookies failed: #{e.message}" }], error: true)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module Crucible
|
|
8
|
+
module Tools
|
|
9
|
+
# Download management tools: set_download_path, list_downloads, wait_for_download, clear_downloads
|
|
10
|
+
module Downloads
|
|
11
|
+
# Tracker to persist download info across navigations (Ferrum clears its list on navigation)
|
|
12
|
+
class Tracker
|
|
13
|
+
def initialize
|
|
14
|
+
@sessions = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_path(session, path)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@sessions[session] ||= { path: nil, files: [], initial_files: [] }
|
|
21
|
+
@sessions[session][:path] = path
|
|
22
|
+
# Capture initial directory state to detect new downloads
|
|
23
|
+
@sessions[session][:initial_files] = Dir.exist?(path) ? Dir.glob(File.join(path, '*')) : []
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_path(session)
|
|
28
|
+
@mutex.synchronize { @sessions.dig(session, :path) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_initial_files(session)
|
|
32
|
+
@mutex.synchronize { @sessions.dig(session, :initial_files) || [] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_files(session, files)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@sessions[session] ||= { path: nil, files: [], initial_files: [] }
|
|
38
|
+
@sessions[session][:files] = (@sessions[session][:files] + files).uniq
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_files(session)
|
|
43
|
+
@mutex.synchronize { @sessions.dig(session, :files) || [] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear_files(session)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
cleared = @sessions.dig(session, :files) || []
|
|
49
|
+
@sessions[session][:files] = [] if @sessions[session]
|
|
50
|
+
cleared
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def remove_session(session)
|
|
55
|
+
@mutex.synchronize { @sessions.delete(session) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
def tools(sessions, _config)
|
|
61
|
+
# Create a shared tracker instance for all tools
|
|
62
|
+
tracker = Tracker.new
|
|
63
|
+
|
|
64
|
+
[
|
|
65
|
+
set_download_path_tool(sessions, tracker),
|
|
66
|
+
list_downloads_tool(sessions, tracker),
|
|
67
|
+
wait_for_download_tool(sessions, tracker),
|
|
68
|
+
clear_downloads_tool(sessions, tracker)
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def set_download_path_tool(sessions, tracker)
|
|
75
|
+
MCP::Tool.define(
|
|
76
|
+
name: 'set_download_path',
|
|
77
|
+
description: 'Set the directory where downloads will be saved',
|
|
78
|
+
input_schema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
session: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'Session name',
|
|
84
|
+
default: 'default'
|
|
85
|
+
},
|
|
86
|
+
path: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
description: 'Directory path for downloads'
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
required: ['path']
|
|
92
|
+
}
|
|
93
|
+
) do |path:, session: 'default', **|
|
|
94
|
+
browser = sessions.get_or_create(session)
|
|
95
|
+
expanded_path = File.expand_path(path)
|
|
96
|
+
|
|
97
|
+
# Create directory if it doesn't exist
|
|
98
|
+
FileUtils.mkdir_p(expanded_path)
|
|
99
|
+
|
|
100
|
+
# Set in Ferrum
|
|
101
|
+
browser.downloads.set_behavior(save_path: expanded_path)
|
|
102
|
+
|
|
103
|
+
# Track in our persistent tracker
|
|
104
|
+
tracker.set_path(session, expanded_path)
|
|
105
|
+
|
|
106
|
+
MCP::Tool::Response.new([{
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: "Download path set to: #{expanded_path}"
|
|
109
|
+
}])
|
|
110
|
+
rescue Ferrum::Error => e
|
|
111
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Failed to set download path: #{e.message}" }], error: true)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def list_downloads_tool(_sessions, tracker)
|
|
116
|
+
MCP::Tool.define(
|
|
117
|
+
name: 'list_downloads',
|
|
118
|
+
description: 'List all downloaded files in the current session',
|
|
119
|
+
input_schema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
session: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Session name',
|
|
125
|
+
default: 'default'
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
required: []
|
|
129
|
+
}
|
|
130
|
+
) do |session: 'default', **|
|
|
131
|
+
# Use our tracker instead of Ferrum's (which gets cleared on navigation)
|
|
132
|
+
files = tracker.get_files(session)
|
|
133
|
+
|
|
134
|
+
if files.empty?
|
|
135
|
+
MCP::Tool::Response.new([{ type: 'text', text: 'No downloads in this session' }])
|
|
136
|
+
else
|
|
137
|
+
result = {
|
|
138
|
+
download_path: tracker.get_path(session),
|
|
139
|
+
count: files.size,
|
|
140
|
+
files: files.map do |file|
|
|
141
|
+
info = { path: file, filename: File.basename(file) }
|
|
142
|
+
if File.exist?(file)
|
|
143
|
+
info[:size] = File.size(file)
|
|
144
|
+
info[:modified] = File.mtime(file).iso8601
|
|
145
|
+
info[:exists] = true
|
|
146
|
+
else
|
|
147
|
+
info[:exists] = false
|
|
148
|
+
end
|
|
149
|
+
info
|
|
150
|
+
end
|
|
151
|
+
}
|
|
152
|
+
MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(result) }])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def wait_for_download_tool(sessions, tracker)
|
|
158
|
+
MCP::Tool.define(
|
|
159
|
+
name: 'wait_for_download',
|
|
160
|
+
description: 'Wait for a download to complete',
|
|
161
|
+
input_schema: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
session: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
description: 'Session name',
|
|
167
|
+
default: 'default'
|
|
168
|
+
},
|
|
169
|
+
timeout: {
|
|
170
|
+
type: 'number',
|
|
171
|
+
description: 'Maximum time to wait in seconds',
|
|
172
|
+
default: 30
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
required: []
|
|
176
|
+
}
|
|
177
|
+
) do |session: 'default', timeout: 30, **|
|
|
178
|
+
browser = sessions.get_or_create(session)
|
|
179
|
+
download_path = tracker.get_path(session)
|
|
180
|
+
|
|
181
|
+
# Try Ferrum's wait first (may or may not work depending on download type)
|
|
182
|
+
begin
|
|
183
|
+
browser.downloads.wait(timeout)
|
|
184
|
+
rescue Ferrum::TimeoutError
|
|
185
|
+
# Timeout is ok - download might have already completed
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Find new files by comparing current directory against:
|
|
189
|
+
# 1. Initial files when set_download_path was called
|
|
190
|
+
# 2. Files we've already tracked
|
|
191
|
+
new_files = []
|
|
192
|
+
if download_path && Dir.exist?(download_path)
|
|
193
|
+
current_files = Dir.glob(File.join(download_path, '*'))
|
|
194
|
+
initial_files = tracker.get_initial_files(session)
|
|
195
|
+
already_tracked = tracker.get_files(session)
|
|
196
|
+
known_files = (initial_files + already_tracked).uniq
|
|
197
|
+
|
|
198
|
+
new_files = current_files - known_files
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Add to our persistent tracker
|
|
202
|
+
tracker.add_files(session, new_files) unless new_files.empty?
|
|
203
|
+
|
|
204
|
+
if new_files.empty?
|
|
205
|
+
tracked_count = tracker.get_files(session).size
|
|
206
|
+
MCP::Tool::Response.new([{
|
|
207
|
+
type: 'text',
|
|
208
|
+
text: "No new downloads. Total tracked files: #{tracked_count}"
|
|
209
|
+
}])
|
|
210
|
+
else
|
|
211
|
+
result = new_files.map do |file|
|
|
212
|
+
info = { path: file, filename: File.basename(file) }
|
|
213
|
+
info[:size] = File.size(file) if File.exist?(file)
|
|
214
|
+
info
|
|
215
|
+
end
|
|
216
|
+
MCP::Tool::Response.new([{
|
|
217
|
+
type: 'text',
|
|
218
|
+
text: "Downloaded: #{JSON.pretty_generate(result)}"
|
|
219
|
+
}])
|
|
220
|
+
end
|
|
221
|
+
rescue Ferrum::Error => e
|
|
222
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Failed waiting for download: #{e.message}" }], error: true)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def clear_downloads_tool(_sessions, tracker)
|
|
227
|
+
MCP::Tool.define(
|
|
228
|
+
name: 'clear_downloads',
|
|
229
|
+
description: 'Clear the list of tracked downloads (optionally delete files)',
|
|
230
|
+
input_schema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
session: {
|
|
234
|
+
type: 'string',
|
|
235
|
+
description: 'Session name',
|
|
236
|
+
default: 'default'
|
|
237
|
+
},
|
|
238
|
+
delete_files: {
|
|
239
|
+
type: 'boolean',
|
|
240
|
+
description: 'Also delete the actual files from disk',
|
|
241
|
+
default: false
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
required: []
|
|
245
|
+
}
|
|
246
|
+
) do |session: 'default', delete_files: false, **|
|
|
247
|
+
files = tracker.clear_files(session)
|
|
248
|
+
|
|
249
|
+
deleted_count = 0
|
|
250
|
+
if delete_files
|
|
251
|
+
files.each do |file|
|
|
252
|
+
if File.exist?(file)
|
|
253
|
+
File.delete(file)
|
|
254
|
+
deleted_count += 1
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
message = if delete_files
|
|
260
|
+
"Cleared #{files.size} download(s), deleted #{deleted_count} file(s)"
|
|
261
|
+
else
|
|
262
|
+
"Cleared #{files.size} download(s) from tracking"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
MCP::Tool::Response.new([{ type: 'text', text: message }])
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
MCP::Tool::Response.new([{ type: 'text', text: "Failed to clear downloads: #{e.message}" }], error: true)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|