resque-mcp 0.2.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: a7aaa6b78cc06fac6720e3b95b382f2bc32b69d9cb8abe72ce9b0ab35c82561f
4
- data.tar.gz: 8b908e0fdb79b0daee88070b2dd8121dad22c251328353759be555b30adf866b
3
+ metadata.gz: 890b5b8eb96c160226ce467a2a626861f25e774e5cd80402a4e8256a5aa361f1
4
+ data.tar.gz: fb3e64270e1d0dd2fca69d4ecedbd9c06b72ff146dd6160b5fc305420bc49117
5
5
  SHA512:
6
- metadata.gz: a977ac62ae2eb47fde93099cc1583967da2186e87dc8aaa8f4bc5b613b50f70c160eb13ffa65327ca639d9a9d050e5b8561a975e39a30d34da7f85b9e1141eff
7
- data.tar.gz: aa95121a61ce913be0702d1be6ee2d40d5ff83fbe080a1d59e8f63c30ecdc91a3f4056c832810b8dcf5922ebbf6e3a164ce6410d86959303fdb8584f349b5b14
6
+ metadata.gz: d066b59609ab3e12a1c0e7458bbf9503751b94f548c858e51aeb5a1cd921aa0675f1643d6d64c2bf83cc524314961104f9d11b5c25ec874731e5726f29799edf
7
+ data.tar.gz: 5e84b9b0249ee94416e9c505abba4637f01d8669dec570bab97a4bedb556a6008497525b4d0f19233c5b002f9228aede5c2ee8ae5e1467ae7a5c17d14d458c32
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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
+
5
11
  ## [0.2.0]
6
12
 
7
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).
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:
@@ -29,11 +33,20 @@ mount Resque::Mcp::Engine => "/resque-mcp"
29
33
  Resque::Mcp.configure do |c|
30
34
  # token could be created by e.g.: `bin/rails runner 'puts SecureRandom.base58(32)'`
31
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]
32
41
  end
33
42
  ```
34
43
 
35
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.
36
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
+
37
50
  Connect Claude Code:
38
51
 
39
52
  ```sh
@@ -45,7 +58,7 @@ Then ask, e.g., "How is my Resque doing?"
45
58
 
46
59
  ## Tools
47
60
 
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.
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.
49
62
 
50
63
  ### `overview` — read-only
51
64
 
@@ -82,6 +95,30 @@ List all queues with sizes, or inspect a single queue. With `queue` and `include
82
95
 
83
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.
84
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
+
85
122
  ### `list_failures` — read-only
86
123
 
87
124
  Page through failed jobs, newest first, as compact records (truncated error, args preview, no backtrace). Optionally filter by `class_name`.
@@ -58,7 +58,10 @@ module Resque
58
58
 
59
59
  def peek(queue, offset:, limit:)
60
60
  ensure_known_queue!(queue)
61
- {size: @resque.size(queue), jobs: to_array(@resque.peek(queue, offset, limit))}
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}
62
65
  end
63
66
 
64
67
  # Newest-first pagination over the failed list. `offset` counts raw
@@ -87,6 +90,16 @@ module Resque
87
90
  normalize_failure(index, item)
88
91
  end
89
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
+
90
103
  # Resque.redis_id can embed user:password@; only this stripped form
91
104
  # may reach tool responses.
92
105
  def redis_identifier
@@ -151,20 +164,39 @@ module Resque
151
164
  }
152
165
  end
153
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
+
154
188
  def normalize_failure(index, item)
155
- payload = item["payload"] || {}
156
- {
189
+ Models::Failure.new(
157
190
  index: index,
158
191
  failed_at: item["failed_at"],
159
192
  queue: item["queue"],
160
- class: payload["class"],
161
- args: payload["args"],
162
193
  exception: item["exception"],
163
194
  error: item["error"],
164
195
  backtrace: item["backtrace"],
165
196
  worker: item["worker"],
166
- retried_at: item["retried_at"]
167
- }
197
+ retried_at: item["retried_at"],
198
+ job: build_job(item["payload"])
199
+ )
168
200
  end
169
201
 
170
202
  # Resque's list reads (Resque.peek, Failure.all) return a bare hash
@@ -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, Tools::QueueStats, Tools::ListFailures, Tools::GetFailure],
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
@@ -38,11 +38,14 @@ module Resque
38
38
  page
39
39
  end
40
40
 
41
- def args_preview(args)
42
- truncate_text(JSON.generate(args), ARGS_PREVIEW_MAX)
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)
43
45
  end
44
46
 
45
- def full_args(args)
47
+ def full_args(job)
48
+ args = job.filtered_args
46
49
  json = JSON.generate(args)
47
50
  (json.length <= ARGS_FULL_MAX) ? args : truncate_text(json, ARGS_FULL_MAX)
48
51
  end
@@ -19,19 +19,19 @@ module Resque
19
19
 
20
20
  def self.call(index:, server_context:, queue: nil, **)
21
21
  record = adapter(server_context).failure(index, queue: queue)
22
- backtrace, omitted = capped_backtrace(record[:backtrace])
22
+ backtrace, omitted = capped_backtrace(record.backtrace)
23
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],
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
31
  backtrace: backtrace,
32
32
  backtrace_omitted: omitted,
33
- worker: record[:worker],
34
- retried_at: record[:retried_at]
33
+ worker: record.worker,
34
+ retried_at: record.retried_at
35
35
  }, server_context)
36
36
  rescue Adapter::FailureOutOfRangeError, Adapter::FailureQueueRequiredError, ArgumentError => e
37
37
  error_response(e.message)
@@ -28,14 +28,14 @@ module Resque
28
28
  )
29
29
  failures = result[:records].map do |record|
30
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]
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
39
  }
40
40
  end
41
41
  success_response({
@@ -32,7 +32,7 @@ module Resque
32
32
  clamped = clamp_limit(limit)
33
33
  result = adapter(server_context).peek(queue, offset: offset, limit: clamped)
34
34
  jobs = result[:jobs].map do |job|
35
- {class: job["class"], args_preview: args_preview(job["args"])}
35
+ {class: job.class_name, args_preview: args_preview(job)}
36
36
  end
37
37
  success_response({
38
38
  queue: queue,
@@ -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.2.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.2.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,12 +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
95
112
  - lib/resque/mcp/tools/get_failure.rb
96
113
  - lib/resque/mcp/tools/list_failures.rb
97
114
  - lib/resque/mcp/tools/overview.rb
98
115
  - lib/resque/mcp/tools/queue_stats.rb
116
+ - lib/resque/mcp/tools/worker_stats.rb
99
117
  - lib/resque/mcp/version.rb
100
118
  - sig/resque/mcp.rbs
101
119
  homepage: https://github.com/jbockler/resque-mcp