resque-mcp 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f899cb2c9bb3c59a5185841550784e81dac79b0848da767f1cd5a81910efc84
4
- data.tar.gz: 26775e429aa90ffcf60ed7b1517f74744d7786f137890376b9263392fd1d5719
3
+ metadata.gz: 890b5b8eb96c160226ce467a2a626861f25e774e5cd80402a4e8256a5aa361f1
4
+ data.tar.gz: fb3e64270e1d0dd2fca69d4ecedbd9c06b72ff146dd6160b5fc305420bc49117
5
5
  SHA512:
6
- metadata.gz: 4185dc24785f9425db9089136725a30ca45995d5dc35883dec9cbdfd0cdb2a47105037d376bc37315097f3136d90c22e2521487bd42351593593b86ea1446b0a
7
- data.tar.gz: f2e8f0558eec1d6cd83589695eddd3dca1703d65130f71530a23596f81540e3c3a3a6611cb5360cac95c3cdf97fa95bced0177c197b12ef4a58d4a82d790ff31
6
+ metadata.gz: d066b59609ab3e12a1c0e7458bbf9503751b94f548c858e51aeb5a1cd921aa0675f1643d6d64c2bf83cc524314961104f9d11b5c25ec874731e5726f29799edf
7
+ data.tar.gz: 5e84b9b0249ee94416e9c505abba4637f01d8669dec570bab97a4bedb556a6008497525b4d0f19233c5b002f9228aede5c2ee8ae5e1467ae7a5c17d14d458c32
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0]
6
+
7
+ - `worker_stats` tool: list registered workers (state, subscribed queues, per-worker processed/failed counts, start time, current job with filtered args preview) with a `state` filter (`working`/`idle`/`all`), global counts including `heartbeat_expired`, and the standard pagination envelope. Workers whose heartbeat is older than `Resque.prune_interval` are flagged as likely dead.
8
+ - Parameter filtering: job args in all tool responses are masked with `[FILTERED]` via `ActiveSupport::ParameterFilter` before any preview/truncation. Inherits `Rails.application.config.filter_parameters` by default; `Resque::Mcp.configure { |c| c.filter_parameters = [...] }` replaces the list (`[]` disables).
9
+ - Internal: the adapter now returns model objects instead of hashes; raw job args are sealed inside `Models::Job` and only accessible filtered. No change to any tool response.
10
+
11
+ ## [0.2.0]
12
+
13
+ - `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).
14
+ - `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.
15
+ - `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.
16
+
5
17
  ## [0.1.0]
6
18
 
7
19
  - Mountable Rails engine serving a stateless MCP Streamable HTTP endpoint (`POST` only; all other verbs answer 405).
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # resque-mcp
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/resque-mcp.svg)](https://rubygems.org/gems/resque-mcp)
4
+
3
5
  An MCP server for [Resque](https://github.com/resque/resque), mountable as a Rails engine. Lets MCP clients (e.g. Claude Code) inspect queues, workers, and failed jobs — and retry or clear failures — over a single authenticated Streamable HTTP endpoint.
4
6
 
5
7
  ## Requirements
@@ -10,13 +12,15 @@ An MCP server for [Resque](https://github.com/resque/resque), mountable as a Rai
10
12
 
11
13
  ## Installation
12
14
 
13
- Not yet published to RubyGems. Until then, install from git:
15
+ Add the gem to your application's Gemfile:
14
16
 
15
17
  ```ruby
16
18
  # Gemfile
17
- gem "resque-mcp", github: "jbockler/resque-mcp"
19
+ gem "resque-mcp"
18
20
  ```
19
21
 
22
+ Then run `bundle install`. Or install it yourself with `gem install resque-mcp`.
23
+
20
24
  ## Usage
21
25
 
22
26
  Mount the engine and configure the auth token:
@@ -27,12 +31,22 @@ mount Resque::Mcp::Engine => "/resque-mcp"
27
31
 
28
32
  # config/initializers/resque_mcp.rb
29
33
  Resque::Mcp.configure do |c|
34
+ # token could be created by e.g.: `bin/rails runner 'puts SecureRandom.base58(32)'`
30
35
  c.auth_token = Rails.application.credentials.dig(:resque_mcp, :token)
36
+
37
+ # Optional: which job-args keys to mask as [FILTERED] in tool responses.
38
+ # Defaults to your app's config.filter_parameters; an explicit list
39
+ # replaces it (merge yourself if you want both):
40
+ # c.filter_parameters = Rails.application.config.filter_parameters + [:iban]
31
41
  end
32
42
  ```
33
43
 
34
44
  The token is **required** — the endpoint answers `503` until one is configured, and `401` on any request without a matching `Authorization: Bearer` header. The engine talks to whatever `Resque.redis` your app already configured; it never opens its own Redis connection.
35
45
 
46
+ Job arguments shown by any tool are filtered through `ActiveSupport::ParameterFilter` **before** preview/truncation, using your Rails `filter_parameters` by default — the same keys you hide from your logs are hidden from the model. Filters match hash keys (at any depth, same semantics as Rails log filtering, including anchored dot-notation like `/\Acredit_card\.code\z/`); positional scalar args have no key and pass through. Set `c.filter_parameters = []` to disable.
47
+
48
+ Scope honestly stated: filtering covers **job args only**. Exception messages and backtraces in failure records are shown unfiltered (Rails doesn't scrub those from logs either) — a secret interpolated into an exception message will be visible, so treat error text accordingly.
49
+
36
50
  Connect Claude Code:
37
51
 
38
52
  ```sh
@@ -44,7 +58,7 @@ Then ask, e.g., "How is my Resque doing?"
44
58
 
45
59
  ## Tools
46
60
 
47
- The tool surface is currently in progress. 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.
61
+ The tool surface is read-only so far (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
62
 
49
63
  ### `overview` — read-only
50
64
 
@@ -63,10 +77,88 @@ Global Resque health snapshot; takes no parameters.
63
77
  - `processed` is Resque's lifetime counter; `failed` is the current number of records in the failed list.
64
78
  - `workers` counts registered workers, `working` those currently running a job.
65
79
 
80
+ ### `queue_stats` — read-only
81
+
82
+ 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:
83
+
84
+ ```json
85
+ {
86
+ "queue": "imports", "size": 84,
87
+ "jobs": [
88
+ { "class": "ImportWorker", "args_preview": "[812, \"s3://bucket/batch-7.csv\"]" },
89
+ { "class": "ImportWorker", "args_preview": "[813, \"s3://bucket/batch-8.csv\"]" }
90
+ ],
91
+ "page": { "total": 84, "offset": 0, "limit": 2, "returned": 2, "has_more": true, "next_offset": 2 },
92
+ "meta": { "…": "…" }
93
+ }
94
+ ```
95
+
96
+ Every paginated response carries this `page` envelope. Limits are capped at 100 — a larger request is clamped and the clamp noted in the response.
97
+
98
+ ### `worker_stats` — read-only
99
+
100
+ List registered workers with their current state, optionally filtered by `state` (`working` / `idle` / `all`):
101
+
102
+ ```json
103
+ {
104
+ "workers": [
105
+ {
106
+ "id": "worker-host-1:4021:imports,default", "state": "working",
107
+ "queues": ["imports", "default"], "started": "2026-06-30 04:12:09 UTC",
108
+ "processed": 48211, "failed": 12, "heartbeat_expired": false,
109
+ "current_job": { "queue": "imports", "class": "ImportWorker",
110
+ "args_preview": "[812, …]", "run_at": "2026-07-02T09:14:55Z" }
111
+ }
112
+ ],
113
+ "counts": { "total": 12, "working": 3, "idle": 9, "heartbeat_expired": 1 },
114
+ "page": { "total": 12, "offset": 0, "limit": 50, "returned": 12, "has_more": false, "next_offset": null },
115
+ "meta": { "…": "…" }
116
+ }
117
+ ```
118
+
119
+ - `heartbeat_expired: true` flags a worker whose last heartbeat is older than `Resque.prune_interval` — it is likely dead and its record stale.
120
+ - `counts` always covers all workers; a `state` filter only narrows the `workers` list (and its `page` envelope).
121
+
122
+ ### `list_failures` — read-only
123
+
124
+ Page through failed jobs, newest first, as compact records (truncated error, args preview, no backtrace). Optionally filter by `class_name`.
125
+
126
+ ```json
127
+ {
128
+ "failures": [
129
+ {
130
+ "index": 1341, "failed_at": "2026/07/02 08:59:12 UTC",
131
+ "queue": "imports", "class": "ImportWorker",
132
+ "args_preview": "[812, \"s3://bucket/batch-7.csv\"]",
133
+ "exception": "PG::ConnectionBad",
134
+ "error": "could not connect to server: Connection refused… (truncated)",
135
+ "retried_at": null
136
+ }
137
+ ],
138
+ "page": { "total": 1342, "offset": 0, "limit": 20, "returned": 20, "has_more": true, "next_offset": 20 },
139
+ "meta": { "…": "…" }
140
+ }
141
+ ```
142
+
143
+ - 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.
144
+ - 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"`.
145
+
146
+ ### `get_failure` — read-only
147
+
148
+ 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.
149
+
150
+ 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.
151
+
152
+ ### Failure backends
153
+
154
+ 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.
155
+
66
156
  ## Development
67
157
 
68
158
  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
159
 
160
+ 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.
161
+
70
162
  ## Contributing
71
163
 
72
164
  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).
@@ -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,59 @@ 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
+ jobs = to_array(@resque.peek(queue, offset, limit)).map do |item|
62
+ build_job(item, queue: queue)
63
+ end
64
+ {size: @resque.size(queue), jobs: jobs}
65
+ end
66
+
67
+ # Newest-first pagination over the failed list. `offset` counts raw
68
+ # list positions back from the newest record; returned indexes are raw
69
+ # list positions (what requeue/remove take). Failures are RPUSH'd so
70
+ # index 0 is the OLDEST record, and Failure.each's 'desc' only
71
+ # reverses within a fetched slice — the newest-first window has to be
72
+ # translated to raw indexes here.
73
+ def failures(offset:, limit:, class_name: nil, queue: nil)
74
+ ensure_failure_queue!(queue)
75
+ raw_total = @resque::Failure.count(queue)
76
+ if class_name.nil?
77
+ unfiltered_failures(offset, limit, raw_total, queue)
78
+ else
79
+ filtered_failures(offset, limit, raw_total, class_name, queue)
80
+ end
81
+ end
82
+
83
+ def failure(index, queue: nil)
84
+ ensure_failure_queue!(queue)
85
+ count = @resque::Failure.count(queue)
86
+ raise FailureOutOfRangeError.new(index, count) unless index >= 0 && index < count
87
+ item = to_array(@resque::Failure.all(index, 1, queue)).first
88
+ # The list can shrink between the count read and the fetch.
89
+ raise FailureOutOfRangeError.new(index, @resque::Failure.count(queue)) if item.nil?
90
+ normalize_failure(index, item)
91
+ end
92
+
93
+ # Normalized snapshot of all registered workers, sorted by id so
94
+ # pagination over the unordered Redis set is deterministic.
95
+ # `started` is an opaque string (never parsed, like failed_at).
96
+ def workers
97
+ expired_ids = @resque::Worker.all_workers_with_expired_heartbeats.map(&:to_s)
98
+ @resque.workers
99
+ .map { |worker| normalize_worker(worker, expired_ids) }
100
+ .sort_by(&:id)
101
+ end
102
+
26
103
  # Resque.redis_id can embed user:password@; only this stripped form
27
104
  # may reach tool responses.
28
105
  def redis_identifier
@@ -31,6 +108,121 @@ module Resque
31
108
 
32
109
  private
33
110
 
111
+ def unfiltered_failures(offset, limit, raw_total, queue)
112
+ available = raw_total - offset
113
+ if available <= 0
114
+ return {records: [], total: raw_total, has_more: false, next_offset: nil}
115
+ end
116
+ count = [limit, available].min
117
+ raw_start = raw_total - offset - count
118
+ items = to_array(@resque::Failure.all(raw_start, count, queue))
119
+ records = items.each_with_index
120
+ .reject { |item, _i| item.nil? }
121
+ .map { |item, i| normalize_failure(raw_start + i, item) }
122
+ .reverse
123
+ has_more = offset + count < raw_total
124
+ {
125
+ records: records,
126
+ total: raw_total,
127
+ has_more: has_more,
128
+ next_offset: has_more ? offset + count : nil
129
+ }
130
+ end
131
+
132
+ # Scans the list tail-backwards in chunks collecting class_name
133
+ # matches. next_offset is a RAW cursor (raw positions consumed from
134
+ # the newest end), not a match count — page arithmetic on it is
135
+ # invalid, callers must pass it back verbatim.
136
+ def filtered_failures(offset, limit, raw_total, class_name, queue)
137
+ records = []
138
+ cursor_after_last = nil
139
+ has_more = false
140
+ pos = offset
141
+ while pos < raw_total && !has_more
142
+ chunk_size = [FILTER_SCAN_CHUNK, raw_total - pos].min
143
+ raw_start = raw_total - pos - chunk_size
144
+ items = to_array(@resque::Failure.all(raw_start, chunk_size, queue))
145
+ items.reverse_each do |item|
146
+ index = raw_total - pos - 1
147
+ pos += 1
148
+ next unless item && item.dig("payload", "class") == class_name
149
+ if records.size < limit
150
+ records << normalize_failure(index, item)
151
+ cursor_after_last = pos
152
+ else
153
+ has_more = true
154
+ break
155
+ end
156
+ end
157
+ end
158
+ {
159
+ records: records,
160
+ total: @resque::Failure.count(queue, class_name),
161
+ total_note: "scan",
162
+ has_more: has_more,
163
+ next_offset: has_more ? cursor_after_last : nil
164
+ }
165
+ end
166
+
167
+ def normalize_worker(worker, expired_ids)
168
+ id = worker.to_s
169
+ job = worker.job
170
+ Models::Worker.new(
171
+ id: id,
172
+ state: job.empty? ? "idle" : "working",
173
+ queues: worker.queues,
174
+ started: worker.started,
175
+ processed: worker.processed,
176
+ failed: worker.failed,
177
+ heartbeat_expired: expired_ids.include?(id),
178
+ current_job: job.empty? ? nil : build_job(job["payload"], queue: job["queue"], run_at: job["run_at"])
179
+ )
180
+ end
181
+
182
+ # The single place mapping resque's payload keys to Models::Job.
183
+ def build_job(payload, queue: nil, run_at: nil)
184
+ payload ||= {}
185
+ Models::Job.new(class_name: payload["class"], args: payload["args"], queue: queue, run_at: run_at)
186
+ end
187
+
188
+ def normalize_failure(index, item)
189
+ Models::Failure.new(
190
+ index: index,
191
+ failed_at: item["failed_at"],
192
+ queue: item["queue"],
193
+ exception: item["exception"],
194
+ error: item["error"],
195
+ backtrace: item["backtrace"],
196
+ worker: item["worker"],
197
+ retried_at: item["retried_at"],
198
+ job: build_job(item["payload"])
199
+ )
200
+ end
201
+
202
+ # Resque's list reads (Resque.peek, Failure.all) return a bare hash
203
+ # (or nil) instead of a list when asked for exactly one item.
204
+ def to_array(items)
205
+ items.is_a?(Array) ? items : [items].compact
206
+ end
207
+
208
+ # On redis_multi_queue, count(nil) sums ALL failure queues while
209
+ # all(..., nil) reads the empty default :failed list — pagination
210
+ # over that mismatch reports has_more forever without yielding a
211
+ # record, so a failure queue must be named explicitly.
212
+ def ensure_failure_queue!(queue)
213
+ return unless queue.nil? && multi_queue_failure_backend?
214
+ raise FailureQueueRequiredError.new(@resque::Failure.queues)
215
+ end
216
+
217
+ def multi_queue_failure_backend?
218
+ @resque::Failure.backend.name == "Resque::Failure::RedisMultiQueue"
219
+ end
220
+
221
+ def ensure_known_queue!(queue)
222
+ known = @resque.queues
223
+ raise UnknownQueueError.new(queue, known) unless known.include?(queue)
224
+ end
225
+
34
226
  # Greedy to the last "@" — passwords may contain "@", "/", or spaces;
35
227
  # over-stripping is acceptable, leaking is not.
36
228
  def strip_userinfo(id)
@@ -4,6 +4,25 @@ module Resque
4
4
  module Mcp
5
5
  class Configuration
6
6
  attr_accessor :auth_token
7
+ # nil = inherit the host default (the engine wires Rails'
8
+ # filter_parameters as a lazy source); an explicit list replaces it
9
+ # ([] disables filtering).
10
+ attr_accessor :filter_parameters
11
+ # Internal seam: a callable returning the host's filter list, read
12
+ # lazily so late additions to Rails' list (or other engines'
13
+ # after_initialize hooks) are never missed.
14
+ attr_accessor :default_filter_parameters
15
+
16
+ # Memoized against the *effective* list, so both reassignment and
17
+ # in-place mutation (config.filter_parameters << :iban) take effect.
18
+ def param_filter
19
+ list = filter_parameters || default_filter_parameters&.call || []
20
+ unless defined?(@param_filter) && @param_filter_list == list
21
+ @param_filter_list = list.dup
22
+ @param_filter = ActiveSupport::ParameterFilter.new(list)
23
+ end
24
+ @param_filter
25
+ end
7
26
  end
8
27
  end
9
28
  end
@@ -6,6 +6,14 @@ module Resque
6
6
  module Mcp
7
7
  class Engine < ::Rails::Engine
8
8
  isolate_namespace Resque::Mcp
9
+
10
+ # A lazy source, not a boot-time snapshot: evaluated per use so
11
+ # post-boot additions to Rails' filter list (or other engines'
12
+ # after_initialize hooks) are never missed. An explicitly configured
13
+ # filter_parameters still takes precedence.
14
+ initializer "resque_mcp.filter_parameters" do
15
+ Resque::Mcp.config.default_filter_parameters = -> { Rails.application.config.filter_parameters }
16
+ end
9
17
  end
10
18
  end
11
19
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resque
4
+ module Mcp
5
+ module Models
6
+ # One failed-job record. `index` is the record's mutable position in
7
+ # the Redis failed list, not a stable id. `failed_at`/`retried_at`
8
+ # are opaque strings. `job` is a Models::Job (sealed args); the
9
+ # origin queue lives here, not on the job.
10
+ Failure = Data.define(
11
+ :index, :failed_at, :queue, :exception, :error,
12
+ :backtrace, :worker, :retried_at, :job
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resque
4
+ module Mcp
5
+ module Models
6
+ # One job payload. Raw args are sealed — filtered access is the only
7
+ # public surface, so no caller can serialize them unfiltered.
8
+ class Job
9
+ attr_reader :class_name, :queue, :run_at
10
+
11
+ def initialize(class_name:, args:, queue: nil, run_at: nil)
12
+ @class_name = class_name
13
+ @args = args
14
+ @queue = queue
15
+ @run_at = run_at
16
+ end
17
+
18
+ # Key-based filtering runs here, before any presentation-layer
19
+ # truncation, so a secret can't survive inside a truncated JSON
20
+ # string. Each hash arg is filtered as its own root, so anchored
21
+ # dot-notation filters (/\Acredit_card\.code\z/) match exactly as
22
+ # in Rails log filtering. Only hash keys can match — bare
23
+ # positional scalars pass through. A raising host filter (e.g. a
24
+ # proc assuming string values) fails CLOSED: args are withheld
25
+ # with an in-band mark, never leaked.
26
+ #
27
+ # Memoization assumes a Job never outlives its request (the
28
+ # adapter is per-request): the filter config and a transient
29
+ # fail-closed result are frozen in at first read. Revisit before
30
+ # ever caching adapter results across requests.
31
+ def filtered_args
32
+ return @filtered_args if defined?(@filtered_args)
33
+ @filtered_args = filter(Resque::Mcp.config.param_filter, @args)
34
+ rescue => e
35
+ @filtered_args = "[args withheld: filter_parameters raised #{e.class}]"
36
+ end
37
+
38
+ # Debug and generic-serialization surfaces must not undo the
39
+ # sealing: the default #inspect renders @args, ActiveSupport's
40
+ # Object#as_json/#to_json walk instance variables, and pp bypasses
41
+ # #inspect via #pretty_print.
42
+ def inspect
43
+ "#<#{self.class.name} class_name=#{@class_name.inspect} " \
44
+ "queue=#{@queue.inspect} run_at=#{@run_at.inspect} args=[sealed]>"
45
+ end
46
+
47
+ def pretty_print(pp)
48
+ pp.text(inspect)
49
+ end
50
+
51
+ def as_json(_options = nil)
52
+ {"class_name" => @class_name, "queue" => @queue, "run_at" => @run_at,
53
+ "args" => filtered_args}
54
+ end
55
+
56
+ private
57
+
58
+ def filter(param_filter, value)
59
+ case value
60
+ when Hash then param_filter.filter(value)
61
+ when Array then value.map { |element| filter(param_filter, element) }
62
+ else value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resque
4
+ module Mcp
5
+ module Models
6
+ # One registered worker. `started` is an opaque string.
7
+ # `current_job` is a Models::Job (with queue/run_at) or nil.
8
+ Worker = Data.define(
9
+ :id, :state, :queues, :started, :processed, :failed,
10
+ :heartbeat_expired, :current_job
11
+ ) do
12
+ def working? = state == "working"
13
+
14
+ def idle? = state == "idle"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -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::WorkerStats, 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,53 @@ 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
+ # Both take a Models::Job — raw args are sealed inside it
42
+ # (filtering already applied); these helpers only truncate.
43
+ def args_preview(job)
44
+ truncate_text(JSON.generate(job.filtered_args), ARGS_PREVIEW_MAX)
45
+ end
46
+
47
+ def full_args(job)
48
+ args = job.filtered_args
49
+ json = JSON.generate(args)
50
+ (json.length <= ARGS_FULL_MAX) ? args : truncate_text(json, ARGS_FULL_MAX)
51
+ end
52
+
53
+ def truncated_error(error)
54
+ error.nil? ? nil : truncate_text(error, ERROR_TRUNCATE_MAX)
55
+ end
56
+
57
+ # => [frames, omitted_count]
58
+ def capped_backtrace(frames)
59
+ frames = frames.is_a?(Array) ? frames : []
60
+ omitted = [frames.size - BACKTRACE_MAX_FRAMES, 0].max
61
+ [frames.first(BACKTRACE_MAX_FRAMES), omitted]
62
+ end
63
+
64
+ def truncate_text(text, max)
65
+ return text if text.length <= max
66
+ "#{text[0, max]}… (truncated)"
67
+ end
68
+
15
69
  def success_response(payload, server_context)
16
70
  body = payload.merge(meta: meta_footer(server_context))
17
71
  ::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.job.class_name,
28
+ args: full_args(record.job),
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.job.class_name,
35
+ args_preview: args_preview(record.job),
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_name, args_preview: args_preview(job)}
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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resque
4
+ module Mcp
5
+ module Tools
6
+ class WorkerStats < Base
7
+ STATES = %w[all working idle].freeze
8
+
9
+ tool_name "worker_stats"
10
+ description "List registered workers: state (working/idle), current job, " \
11
+ "queues subscribed, per-worker processed/failed counts, start time. " \
12
+ "Flags workers with expired heartbeats (likely dead)."
13
+ input_schema(
14
+ properties: {
15
+ state: {type: "string", enum: ["working", "idle", "all"], default: "all"},
16
+ offset: {type: "integer", default: 0, minimum: 0},
17
+ limit: {type: "integer", default: 50, minimum: 1, maximum: 100}
18
+ },
19
+ required: []
20
+ )
21
+ annotations(read_only_hint: true)
22
+
23
+ def self.call(server_context:, state: "all", offset: 0, limit: 50, **)
24
+ unless STATES.include?(state)
25
+ return error_response("state must be one of: #{STATES.join(", ")}")
26
+ end
27
+
28
+ all = adapter(server_context).workers
29
+ counts = {
30
+ total: all.size,
31
+ working: all.count(&:working?),
32
+ idle: all.count(&:idle?),
33
+ heartbeat_expired: all.count(&:heartbeat_expired)
34
+ }
35
+ selected = (state == "all") ? all : all.select { |w| w.state == state }
36
+ clamped = clamp_limit(limit)
37
+ workers = (selected[offset, clamped] || []).map do |record|
38
+ job = record.current_job
39
+ record.to_h.merge(current_job: job && {
40
+ queue: job.queue,
41
+ class: job.class_name,
42
+ args_preview: args_preview(job),
43
+ run_at: job.run_at
44
+ })
45
+ end
46
+ success_response({
47
+ workers: workers,
48
+ counts: counts,
49
+ page: page_envelope(
50
+ total: selected.size, offset: offset, limit: clamped,
51
+ returned: workers.size, requested_limit: limit
52
+ )
53
+ }, server_context)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Resque
4
4
  module Mcp
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/resque/mcp.rb CHANGED
@@ -4,6 +4,7 @@ require "json"
4
4
  require "resque"
5
5
  require "mcp"
6
6
  require "zeitwerk"
7
+ require "active_support/parameter_filter"
7
8
 
8
9
  require_relative "mcp/version"
9
10
 
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josch Bockler
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - "~>"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0.22'
46
+ - !ruby/object:Gem::Dependency
47
+ name: activesupport
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '7.2'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '7.2'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: railties
48
62
  requirement: !ruby/object:Gem::Requirement
@@ -90,9 +104,16 @@ files:
90
104
  - lib/resque/mcp/adapter.rb
91
105
  - lib/resque/mcp/configuration.rb
92
106
  - lib/resque/mcp/engine.rb
107
+ - lib/resque/mcp/models/failure.rb
108
+ - lib/resque/mcp/models/job.rb
109
+ - lib/resque/mcp/models/worker.rb
93
110
  - lib/resque/mcp/server_factory.rb
94
111
  - lib/resque/mcp/tools/base.rb
112
+ - lib/resque/mcp/tools/get_failure.rb
113
+ - lib/resque/mcp/tools/list_failures.rb
95
114
  - lib/resque/mcp/tools/overview.rb
115
+ - lib/resque/mcp/tools/queue_stats.rb
116
+ - lib/resque/mcp/tools/worker_stats.rb
96
117
  - lib/resque/mcp/version.rb
97
118
  - sig/resque/mcp.rbs
98
119
  homepage: https://github.com/jbockler/resque-mcp