resque-mcp 0.1.0 → 0.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +56 -1
- data/lib/resque/mcp/adapter.rb +160 -0
- data/lib/resque/mcp/server_factory.rb +1 -1
- data/lib/resque/mcp/tools/base.rb +52 -1
- data/lib/resque/mcp/tools/get_failure.rb +42 -0
- data/lib/resque/mcp/tools/list_failures.rb +56 -0
- data/lib/resque/mcp/tools/queue_stats.rb +52 -0
- data/lib/resque/mcp/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7aaa6b78cc06fac6720e3b95b382f2bc32b69d9cb8abe72ce9b0ab35c82561f
|
|
4
|
+
data.tar.gz: 8b908e0fdb79b0daee88070b2dd8121dad22c251328353759be555b30adf866b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a977ac62ae2eb47fde93099cc1583967da2186e87dc8aaa8f4bc5b613b50f70c160eb13ffa65327ca639d9a9d050e5b8561a975e39a30d34da7f85b9e1141eff
|
|
7
|
+
data.tar.gz: aa95121a61ce913be0702d1be6ee2d40d5ff83fbe080a1d59e8f63c30ecdc91a3f4056c832810b8dcf5922ebbf6e3a164ce6410d86959303fdb8584f349b5b14
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.0]
|
|
6
|
+
|
|
7
|
+
- `list_failures` tool: page through failed jobs newest-first with compact records (truncated error, args preview, no backtrace); optional `class_name` filter (filtered totals are marked `"total_note": "scan"` and paging must follow the returned `next_offset` cursor).
|
|
8
|
+
- `get_failure` tool: full detail for one failed job by list index — complete args (capped at a few KB), exception, backtrace capped at 30 frames with `backtrace_omitted`, worker, retry stamp. Out-of-range indexes answer with the current failure count. On the redis_multi_queue backend both failure tools require an explicit `queue` and answer a dedicated error naming the failure queues when it is missing. Failures are addressed by `index`, not `id` — the position shifts when records are removed.
|
|
9
|
+
- `queue_stats` tool: list all queues with sizes, or inspect one queue and page through its pending job payloads (`include_jobs: true`); paginated responses carry a `page` envelope (`total`/`offset`/`limit`/`returned`/`has_more`/`next_offset`), limits above 100 are clamped with an in-band note, and job args are shown as truncated previews.
|
|
10
|
+
|
|
5
11
|
## [0.1.0]
|
|
6
12
|
|
|
7
13
|
- Mountable Rails engine serving a stateless MCP Streamable HTTP endpoint (`POST` only; all other verbs answer 405).
|
data/README.md
CHANGED
|
@@ -27,6 +27,7 @@ mount Resque::Mcp::Engine => "/resque-mcp"
|
|
|
27
27
|
|
|
28
28
|
# config/initializers/resque_mcp.rb
|
|
29
29
|
Resque::Mcp.configure do |c|
|
|
30
|
+
# token could be created by e.g.: `bin/rails runner 'puts SecureRandom.base58(32)'`
|
|
30
31
|
c.auth_token = Rails.application.credentials.dig(:resque_mcp, :token)
|
|
31
32
|
end
|
|
32
33
|
```
|
|
@@ -44,7 +45,7 @@ Then ask, e.g., "How is my Resque doing?"
|
|
|
44
45
|
|
|
45
46
|
## Tools
|
|
46
47
|
|
|
47
|
-
The tool surface is
|
|
48
|
+
The tool surface is read-only so far (worker inspection and failure retry/clear are planned). Every tool response returns structured JSON alongside a text body and ends in a `meta` footer naming the Rails environment and the Redis target (with any credentials stripped), so you always see what you are talking to.
|
|
48
49
|
|
|
49
50
|
### `overview` — read-only
|
|
50
51
|
|
|
@@ -63,10 +64,64 @@ Global Resque health snapshot; takes no parameters.
|
|
|
63
64
|
- `processed` is Resque's lifetime counter; `failed` is the current number of records in the failed list.
|
|
64
65
|
- `workers` counts registered workers, `working` those currently running a job.
|
|
65
66
|
|
|
67
|
+
### `queue_stats` — read-only
|
|
68
|
+
|
|
69
|
+
List all queues with sizes, or inspect a single queue. With `queue` and `include_jobs: true` it pages through the queue's pending job payloads:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"queue": "imports", "size": 84,
|
|
74
|
+
"jobs": [
|
|
75
|
+
{ "class": "ImportWorker", "args_preview": "[812, \"s3://bucket/batch-7.csv\"]" },
|
|
76
|
+
{ "class": "ImportWorker", "args_preview": "[813, \"s3://bucket/batch-8.csv\"]" }
|
|
77
|
+
],
|
|
78
|
+
"page": { "total": 84, "offset": 0, "limit": 2, "returned": 2, "has_more": true, "next_offset": 2 },
|
|
79
|
+
"meta": { "…": "…" }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Every paginated response carries this `page` envelope. Limits are capped at 100 — a larger request is clamped and the clamp noted in the response.
|
|
84
|
+
|
|
85
|
+
### `list_failures` — read-only
|
|
86
|
+
|
|
87
|
+
Page through failed jobs, newest first, as compact records (truncated error, args preview, no backtrace). Optionally filter by `class_name`.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"failures": [
|
|
92
|
+
{
|
|
93
|
+
"index": 1341, "failed_at": "2026/07/02 08:59:12 UTC",
|
|
94
|
+
"queue": "imports", "class": "ImportWorker",
|
|
95
|
+
"args_preview": "[812, \"s3://bucket/batch-7.csv\"]",
|
|
96
|
+
"exception": "PG::ConnectionBad",
|
|
97
|
+
"error": "could not connect to server: Connection refused… (truncated)",
|
|
98
|
+
"retried_at": null
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"page": { "total": 1342, "offset": 0, "limit": 20, "returned": 20, "has_more": true, "next_offset": 20 },
|
|
102
|
+
"meta": { "…": "…" }
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- A failure's `index` is its position in the failed list, deliberately not called an `id`: indexes shift when failures are removed, so re-list after any removal.
|
|
107
|
+
- Under a `class_name` filter, `next_offset` is a raw scan cursor — continue paging with the returned value, never compute `offset + limit` yourself. Filtered totals cost a full-list scan and are marked `"total_note": "scan"`.
|
|
108
|
+
|
|
109
|
+
### `get_failure` — read-only
|
|
110
|
+
|
|
111
|
+
Full detail for one failed job by `index`: complete args, exception, full error, backtrace (capped at 30 frames, with `backtrace_omitted`), and the worker that failed it.
|
|
112
|
+
|
|
113
|
+
All truncation anywhere in the tool surface is marked in-band (`"… (truncated)"`, `backtrace_omitted`), so a model always knows when it is seeing an excerpt.
|
|
114
|
+
|
|
115
|
+
### Failure backends
|
|
116
|
+
|
|
117
|
+
Both failure tools support Resque's `redis_multi_queue` failure backend: pass the failure-queue name as `queue`. On that backend the tools require it and answer with the list of failure queues when it is missing. On the default backend, `queue` can simply be omitted.
|
|
118
|
+
|
|
66
119
|
## Development
|
|
67
120
|
|
|
68
121
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake` to run the tests and standardrb. You can also run `bin/console` for an interactive prompt.
|
|
69
122
|
|
|
123
|
+
To release a new version, update the changelog and the version number in version.rb, commit it, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
|
|
124
|
+
|
|
70
125
|
## Contributing
|
|
71
126
|
|
|
72
127
|
Bug reports and pull requests are welcome on GitHub at https://github.com/jbockler/resque-mcp. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/jbockler/resque-mcp/blob/main/CODE_OF_CONDUCT.md).
|
data/lib/resque/mcp/adapter.rb
CHANGED
|
@@ -4,6 +4,30 @@ module Resque
|
|
|
4
4
|
module Mcp
|
|
5
5
|
# The only code in the gem that talks to Resque. Rails-free.
|
|
6
6
|
class Adapter
|
|
7
|
+
class UnknownQueueError < StandardError
|
|
8
|
+
def initialize(queue, known_queues)
|
|
9
|
+
known = known_queues.empty? ? "(none)" : known_queues.sort.join(", ")
|
|
10
|
+
super("Unknown queue #{queue.inspect}. Known queues: #{known}")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class FailureOutOfRangeError < StandardError
|
|
15
|
+
def initialize(index, count)
|
|
16
|
+
super("No failure at index #{index}. Current failure count: #{count}" \
|
|
17
|
+
"#{" (valid indexes 0..#{count - 1})" if count > 0}.")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class FailureQueueRequiredError < StandardError
|
|
22
|
+
def initialize(known_queues)
|
|
23
|
+
super("The redis_multi_queue failure backend is active — pass `queue` " \
|
|
24
|
+
"with one of the failure queues: #{known_queues.sort.join(", ")}.")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Chunk size for the tail-backwards scan under a class_name filter.
|
|
29
|
+
FILTER_SCAN_CHUNK = 100
|
|
30
|
+
|
|
7
31
|
def initialize(resque: ::Resque)
|
|
8
32
|
@resque = resque
|
|
9
33
|
end
|
|
@@ -23,6 +47,46 @@ module Resque
|
|
|
23
47
|
}
|
|
24
48
|
end
|
|
25
49
|
|
|
50
|
+
def queues
|
|
51
|
+
@resque.queue_sizes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def queue_size(queue)
|
|
55
|
+
ensure_known_queue!(queue)
|
|
56
|
+
@resque.size(queue)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def peek(queue, offset:, limit:)
|
|
60
|
+
ensure_known_queue!(queue)
|
|
61
|
+
{size: @resque.size(queue), jobs: to_array(@resque.peek(queue, offset, limit))}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Newest-first pagination over the failed list. `offset` counts raw
|
|
65
|
+
# list positions back from the newest record; returned indexes are raw
|
|
66
|
+
# list positions (what requeue/remove take). Failures are RPUSH'd so
|
|
67
|
+
# index 0 is the OLDEST record, and Failure.each's 'desc' only
|
|
68
|
+
# reverses within a fetched slice — the newest-first window has to be
|
|
69
|
+
# translated to raw indexes here.
|
|
70
|
+
def failures(offset:, limit:, class_name: nil, queue: nil)
|
|
71
|
+
ensure_failure_queue!(queue)
|
|
72
|
+
raw_total = @resque::Failure.count(queue)
|
|
73
|
+
if class_name.nil?
|
|
74
|
+
unfiltered_failures(offset, limit, raw_total, queue)
|
|
75
|
+
else
|
|
76
|
+
filtered_failures(offset, limit, raw_total, class_name, queue)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def failure(index, queue: nil)
|
|
81
|
+
ensure_failure_queue!(queue)
|
|
82
|
+
count = @resque::Failure.count(queue)
|
|
83
|
+
raise FailureOutOfRangeError.new(index, count) unless index >= 0 && index < count
|
|
84
|
+
item = to_array(@resque::Failure.all(index, 1, queue)).first
|
|
85
|
+
# The list can shrink between the count read and the fetch.
|
|
86
|
+
raise FailureOutOfRangeError.new(index, @resque::Failure.count(queue)) if item.nil?
|
|
87
|
+
normalize_failure(index, item)
|
|
88
|
+
end
|
|
89
|
+
|
|
26
90
|
# Resque.redis_id can embed user:password@; only this stripped form
|
|
27
91
|
# may reach tool responses.
|
|
28
92
|
def redis_identifier
|
|
@@ -31,6 +95,102 @@ module Resque
|
|
|
31
95
|
|
|
32
96
|
private
|
|
33
97
|
|
|
98
|
+
def unfiltered_failures(offset, limit, raw_total, queue)
|
|
99
|
+
available = raw_total - offset
|
|
100
|
+
if available <= 0
|
|
101
|
+
return {records: [], total: raw_total, has_more: false, next_offset: nil}
|
|
102
|
+
end
|
|
103
|
+
count = [limit, available].min
|
|
104
|
+
raw_start = raw_total - offset - count
|
|
105
|
+
items = to_array(@resque::Failure.all(raw_start, count, queue))
|
|
106
|
+
records = items.each_with_index
|
|
107
|
+
.reject { |item, _i| item.nil? }
|
|
108
|
+
.map { |item, i| normalize_failure(raw_start + i, item) }
|
|
109
|
+
.reverse
|
|
110
|
+
has_more = offset + count < raw_total
|
|
111
|
+
{
|
|
112
|
+
records: records,
|
|
113
|
+
total: raw_total,
|
|
114
|
+
has_more: has_more,
|
|
115
|
+
next_offset: has_more ? offset + count : nil
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Scans the list tail-backwards in chunks collecting class_name
|
|
120
|
+
# matches. next_offset is a RAW cursor (raw positions consumed from
|
|
121
|
+
# the newest end), not a match count — page arithmetic on it is
|
|
122
|
+
# invalid, callers must pass it back verbatim.
|
|
123
|
+
def filtered_failures(offset, limit, raw_total, class_name, queue)
|
|
124
|
+
records = []
|
|
125
|
+
cursor_after_last = nil
|
|
126
|
+
has_more = false
|
|
127
|
+
pos = offset
|
|
128
|
+
while pos < raw_total && !has_more
|
|
129
|
+
chunk_size = [FILTER_SCAN_CHUNK, raw_total - pos].min
|
|
130
|
+
raw_start = raw_total - pos - chunk_size
|
|
131
|
+
items = to_array(@resque::Failure.all(raw_start, chunk_size, queue))
|
|
132
|
+
items.reverse_each do |item|
|
|
133
|
+
index = raw_total - pos - 1
|
|
134
|
+
pos += 1
|
|
135
|
+
next unless item && item.dig("payload", "class") == class_name
|
|
136
|
+
if records.size < limit
|
|
137
|
+
records << normalize_failure(index, item)
|
|
138
|
+
cursor_after_last = pos
|
|
139
|
+
else
|
|
140
|
+
has_more = true
|
|
141
|
+
break
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
{
|
|
146
|
+
records: records,
|
|
147
|
+
total: @resque::Failure.count(queue, class_name),
|
|
148
|
+
total_note: "scan",
|
|
149
|
+
has_more: has_more,
|
|
150
|
+
next_offset: has_more ? cursor_after_last : nil
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_failure(index, item)
|
|
155
|
+
payload = item["payload"] || {}
|
|
156
|
+
{
|
|
157
|
+
index: index,
|
|
158
|
+
failed_at: item["failed_at"],
|
|
159
|
+
queue: item["queue"],
|
|
160
|
+
class: payload["class"],
|
|
161
|
+
args: payload["args"],
|
|
162
|
+
exception: item["exception"],
|
|
163
|
+
error: item["error"],
|
|
164
|
+
backtrace: item["backtrace"],
|
|
165
|
+
worker: item["worker"],
|
|
166
|
+
retried_at: item["retried_at"]
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Resque's list reads (Resque.peek, Failure.all) return a bare hash
|
|
171
|
+
# (or nil) instead of a list when asked for exactly one item.
|
|
172
|
+
def to_array(items)
|
|
173
|
+
items.is_a?(Array) ? items : [items].compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# On redis_multi_queue, count(nil) sums ALL failure queues while
|
|
177
|
+
# all(..., nil) reads the empty default :failed list — pagination
|
|
178
|
+
# over that mismatch reports has_more forever without yielding a
|
|
179
|
+
# record, so a failure queue must be named explicitly.
|
|
180
|
+
def ensure_failure_queue!(queue)
|
|
181
|
+
return unless queue.nil? && multi_queue_failure_backend?
|
|
182
|
+
raise FailureQueueRequiredError.new(@resque::Failure.queues)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def multi_queue_failure_backend?
|
|
186
|
+
@resque::Failure.backend.name == "Resque::Failure::RedisMultiQueue"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def ensure_known_queue!(queue)
|
|
190
|
+
known = @resque.queues
|
|
191
|
+
raise UnknownQueueError.new(queue, known) unless known.include?(queue)
|
|
192
|
+
end
|
|
193
|
+
|
|
34
194
|
# Greedy to the last "@" — passwords may contain "@", "/", or spaces;
|
|
35
195
|
# over-stripping is acceptable, leaking is not.
|
|
36
196
|
def strip_userinfo(id)
|
|
@@ -9,7 +9,7 @@ module Resque
|
|
|
9
9
|
::MCP::Server.new(
|
|
10
10
|
name: "resque-mcp",
|
|
11
11
|
version: Resque::Mcp::VERSION,
|
|
12
|
-
tools: [Tools::Overview],
|
|
12
|
+
tools: [Tools::Overview, Tools::QueueStats, Tools::ListFailures, Tools::GetFailure],
|
|
13
13
|
server_context: {adapter: Adapter.new, environment: environment}
|
|
14
14
|
)
|
|
15
15
|
end
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
module Resque
|
|
4
4
|
module Mcp
|
|
5
5
|
module Tools
|
|
6
|
-
# Shared tool helpers: adapter access, response envelopes, meta footer
|
|
6
|
+
# Shared tool helpers: adapter access, response envelopes, meta footer,
|
|
7
|
+
# pagination and size caps (size policy lives here, not in tools).
|
|
7
8
|
class Base < ::MCP::Tool
|
|
9
|
+
LIMIT_MAX = 100
|
|
10
|
+
ARGS_PREVIEW_MAX = 200
|
|
11
|
+
ERROR_TRUNCATE_MAX = 200
|
|
12
|
+
BACKTRACE_MAX_FRAMES = 30
|
|
13
|
+
ARGS_FULL_MAX = 4096
|
|
14
|
+
|
|
8
15
|
class << self
|
|
9
16
|
private
|
|
10
17
|
|
|
@@ -12,6 +19,50 @@ module Resque
|
|
|
12
19
|
server_context.fetch(:adapter)
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
def clamp_limit(limit)
|
|
23
|
+
[limit, LIMIT_MAX].min
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# has_more/next_offset default to offset arithmetic; tools whose
|
|
27
|
+
# adapter owns the cursor semantics (failures) pass them in.
|
|
28
|
+
def page_envelope(total:, offset:, limit:, returned:, requested_limit: limit,
|
|
29
|
+
has_more: nil, next_offset: :compute, total_note: nil)
|
|
30
|
+
has_more = offset + returned < total if has_more.nil?
|
|
31
|
+
next_offset = has_more ? offset + returned : nil if next_offset == :compute
|
|
32
|
+
page = {
|
|
33
|
+
total: total, offset: offset, limit: limit, returned: returned,
|
|
34
|
+
has_more: has_more, next_offset: next_offset
|
|
35
|
+
}
|
|
36
|
+
page[:note] = "limit clamped to #{LIMIT_MAX}" if requested_limit > limit
|
|
37
|
+
page[:total_note] = total_note if total_note
|
|
38
|
+
page
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def args_preview(args)
|
|
42
|
+
truncate_text(JSON.generate(args), ARGS_PREVIEW_MAX)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def full_args(args)
|
|
46
|
+
json = JSON.generate(args)
|
|
47
|
+
(json.length <= ARGS_FULL_MAX) ? args : truncate_text(json, ARGS_FULL_MAX)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def truncated_error(error)
|
|
51
|
+
error.nil? ? nil : truncate_text(error, ERROR_TRUNCATE_MAX)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# => [frames, omitted_count]
|
|
55
|
+
def capped_backtrace(frames)
|
|
56
|
+
frames = frames.is_a?(Array) ? frames : []
|
|
57
|
+
omitted = [frames.size - BACKTRACE_MAX_FRAMES, 0].max
|
|
58
|
+
[frames.first(BACKTRACE_MAX_FRAMES), omitted]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def truncate_text(text, max)
|
|
62
|
+
return text if text.length <= max
|
|
63
|
+
"#{text[0, max]}… (truncated)"
|
|
64
|
+
end
|
|
65
|
+
|
|
15
66
|
def success_response(payload, server_context)
|
|
16
67
|
body = payload.merge(meta: meta_footer(server_context))
|
|
17
68
|
::MCP::Tool::Response.new(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Resque
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class GetFailure < Base
|
|
7
|
+
tool_name "get_failure"
|
|
8
|
+
description "Full detail for one failed job by list index (as returned by " \
|
|
9
|
+
"list_failures — indexes shift when failures are removed): complete args, " \
|
|
10
|
+
"exception, backtrace (capped at 30 frames), worker that failed it."
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
index: {type: "integer", minimum: 0},
|
|
14
|
+
queue: {type: "string", description: "Failure queue (only relevant with the redis_multi_queue backend; use the same value as in list_failures)"}
|
|
15
|
+
},
|
|
16
|
+
required: ["index"]
|
|
17
|
+
)
|
|
18
|
+
annotations(read_only_hint: true)
|
|
19
|
+
|
|
20
|
+
def self.call(index:, server_context:, queue: nil, **)
|
|
21
|
+
record = adapter(server_context).failure(index, queue: queue)
|
|
22
|
+
backtrace, omitted = capped_backtrace(record[:backtrace])
|
|
23
|
+
success_response({
|
|
24
|
+
index: record[:index],
|
|
25
|
+
failed_at: record[:failed_at],
|
|
26
|
+
queue: record[:queue],
|
|
27
|
+
class: record[:class],
|
|
28
|
+
args: full_args(record[:args]),
|
|
29
|
+
exception: record[:exception],
|
|
30
|
+
error: record[:error],
|
|
31
|
+
backtrace: backtrace,
|
|
32
|
+
backtrace_omitted: omitted,
|
|
33
|
+
worker: record[:worker],
|
|
34
|
+
retried_at: record[:retried_at]
|
|
35
|
+
}, server_context)
|
|
36
|
+
rescue Adapter::FailureOutOfRangeError, Adapter::FailureQueueRequiredError, ArgumentError => e
|
|
37
|
+
error_response(e.message)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Resque
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class ListFailures < Base
|
|
7
|
+
tool_name "list_failures"
|
|
8
|
+
description "Page through failed jobs, newest first. Compact records without " \
|
|
9
|
+
"backtraces — use get_failure for full detail. Each record's `index` is its " \
|
|
10
|
+
"position in the failed list, not a stable id: indexes shift when failures " \
|
|
11
|
+
"are removed, so re-list after any removal. Continue paging with the " \
|
|
12
|
+
"returned page.next_offset — do not compute offsets yourself."
|
|
13
|
+
input_schema(
|
|
14
|
+
properties: {
|
|
15
|
+
offset: {type: "integer", default: 0, minimum: 0},
|
|
16
|
+
limit: {type: "integer", default: 20, minimum: 1, maximum: 100},
|
|
17
|
+
class_name: {type: "string", description: "Only failures of this job class"},
|
|
18
|
+
queue: {type: "string", description: "Failure queue (only relevant with the redis_multi_queue backend)"}
|
|
19
|
+
},
|
|
20
|
+
required: []
|
|
21
|
+
)
|
|
22
|
+
annotations(read_only_hint: true)
|
|
23
|
+
|
|
24
|
+
def self.call(server_context:, offset: 0, limit: 20, class_name: nil, queue: nil, **)
|
|
25
|
+
clamped = clamp_limit(limit)
|
|
26
|
+
result = adapter(server_context).failures(
|
|
27
|
+
offset: offset, limit: clamped, class_name: class_name, queue: queue
|
|
28
|
+
)
|
|
29
|
+
failures = result[:records].map do |record|
|
|
30
|
+
{
|
|
31
|
+
index: record[:index],
|
|
32
|
+
failed_at: record[:failed_at],
|
|
33
|
+
queue: record[:queue],
|
|
34
|
+
class: record[:class],
|
|
35
|
+
args_preview: args_preview(record[:args]),
|
|
36
|
+
exception: record[:exception],
|
|
37
|
+
error: truncated_error(record[:error]),
|
|
38
|
+
retried_at: record[:retried_at]
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
success_response({
|
|
42
|
+
failures: failures,
|
|
43
|
+
page: page_envelope(
|
|
44
|
+
total: result[:total], offset: offset, limit: clamped,
|
|
45
|
+
returned: failures.size, requested_limit: limit,
|
|
46
|
+
has_more: result[:has_more], next_offset: result[:next_offset],
|
|
47
|
+
total_note: result[:total_note]
|
|
48
|
+
)
|
|
49
|
+
}, server_context)
|
|
50
|
+
rescue Adapter::FailureQueueRequiredError, ArgumentError => e
|
|
51
|
+
error_response(e.message)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Resque
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class QueueStats < Base
|
|
7
|
+
tool_name "queue_stats"
|
|
8
|
+
description "List queues with sizes. Pass `queue` to inspect one queue and " \
|
|
9
|
+
"optionally page through its pending jobs (`include_jobs: true`)."
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
queue: {type: "string", description: "Inspect a single queue by name"},
|
|
13
|
+
include_jobs: {type: "boolean", default: false, description: "Include pending job payloads (only with queue)"},
|
|
14
|
+
offset: {type: "integer", default: 0, minimum: 0},
|
|
15
|
+
limit: {type: "integer", default: 20, minimum: 1, maximum: 100}
|
|
16
|
+
},
|
|
17
|
+
required: []
|
|
18
|
+
)
|
|
19
|
+
annotations(read_only_hint: true)
|
|
20
|
+
|
|
21
|
+
def self.call(server_context:, queue: nil, include_jobs: false, offset: 0, limit: 20, **)
|
|
22
|
+
if queue.nil?
|
|
23
|
+
return error_response("include_jobs requires queue") if include_jobs
|
|
24
|
+
return success_response({queues: adapter(server_context).queues}, server_context)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless include_jobs
|
|
28
|
+
size = adapter(server_context).queue_size(queue)
|
|
29
|
+
return success_response({queue: queue, size: size}, server_context)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
clamped = clamp_limit(limit)
|
|
33
|
+
result = adapter(server_context).peek(queue, offset: offset, limit: clamped)
|
|
34
|
+
jobs = result[:jobs].map do |job|
|
|
35
|
+
{class: job["class"], args_preview: args_preview(job["args"])}
|
|
36
|
+
end
|
|
37
|
+
success_response({
|
|
38
|
+
queue: queue,
|
|
39
|
+
size: result[:size],
|
|
40
|
+
jobs: jobs,
|
|
41
|
+
page: page_envelope(
|
|
42
|
+
total: result[:size], offset: offset, limit: clamped,
|
|
43
|
+
returned: jobs.size, requested_limit: limit
|
|
44
|
+
)
|
|
45
|
+
}, server_context)
|
|
46
|
+
rescue Adapter::UnknownQueueError => e
|
|
47
|
+
error_response(e.message)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/resque/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: resque-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Josch Bockler
|
|
@@ -92,7 +92,10 @@ files:
|
|
|
92
92
|
- lib/resque/mcp/engine.rb
|
|
93
93
|
- lib/resque/mcp/server_factory.rb
|
|
94
94
|
- lib/resque/mcp/tools/base.rb
|
|
95
|
+
- lib/resque/mcp/tools/get_failure.rb
|
|
96
|
+
- lib/resque/mcp/tools/list_failures.rb
|
|
95
97
|
- lib/resque/mcp/tools/overview.rb
|
|
98
|
+
- lib/resque/mcp/tools/queue_stats.rb
|
|
96
99
|
- lib/resque/mcp/version.rb
|
|
97
100
|
- sig/resque/mcp.rbs
|
|
98
101
|
homepage: https://github.com/jbockler/resque-mcp
|