resque-mcp 0.2.0 → 0.4.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: 0415f5a9bbc7dddcc1c4def5de45835d49df86158b5261567af19ece94d6d36c
4
+ data.tar.gz: 6f4f8511f80a3107abd83e7e24053d0322dabb5feec212e52271bfe7bd844684
5
5
  SHA512:
6
- metadata.gz: a977ac62ae2eb47fde93099cc1583967da2186e87dc8aaa8f4bc5b613b50f70c160eb13ffa65327ca639d9a9d050e5b8561a975e39a30d34da7f85b9e1141eff
7
- data.tar.gz: aa95121a61ce913be0702d1be6ee2d40d5ff83fbe080a1d59e8f63c30ecdc91a3f4056c832810b8dcf5922ebbf6e3a164ce6410d86959303fdb8584f349b5b14
6
+ metadata.gz: 95a256a4a7e92257568a474254fbd052306f0e915cebad24bfc6ff237a4a678ead0ddfae17611b0f6ab0f4b2f9a195ace9cc4eda54cd0b2d5122afc0827ebdb3
7
+ data.tar.gz: 2494d7df431b2b14f5a27eabc148f6a19670246afbb7051ad0144c2637773c52acaeb5d022b86e0e47d87cf691d044f6e6e1574c8640dd0389f8356189a494cf
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0]
6
+
7
+ - Security: bump the minimum `mcp` dependency to `>= 0.23, < 1` for the DNS-rebinding fix (CVE-2026-63118); `<= 0.22` did not validate `Host`/`Origin` headers on the Streamable HTTP transport. Protection is now on by default, with loopback always allowed.
8
+ - Config: `allowed_hosts` and `allowed_origins` for the DNS-rebinding allowlists. `allowed_hosts` defaults to inheriting the host's Rails `config.hosts` string entries, so apps that already configure Rails host authorization need nothing extra; an explicit list replaces it, and `[]` allows loopback only.
9
+ - Config: `mcp_transport_options` forwards arbitrary settings to the mcp gem's transport (e.g. `max_request_bytes`); the DNS-rebinding keys always override it, so it can tune but never weaken protection.
10
+
11
+ ## [0.3.0]
12
+
13
+ - `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.
14
+ - 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).
15
+ - 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.
16
+
5
17
  ## [0.2.0]
6
18
 
7
19
  - `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,10 +33,35 @@ 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
+ # DNS-rebinding protection (CVE-2026-63118) rejects any Host outside the
38
+ # allowlist with 403; loopback (127.0.0.1/::1/localhost) is always allowed.
39
+ # By default the allowlist is inherited from your Rails config.hosts, so if
40
+ # that already lists your domain(s) you need nothing here. Only plain
41
+ # hostname strings are honored (here and when inherited) — regexps, IPAddrs,
42
+ # and ".sub.domain" wildcards are dropped, so give concrete hostnames:
43
+ # c.allowed_hosts = ["resque.example.com"]
44
+
45
+ # Optional: extra permitted Origin values beyond same-origin.
46
+ # c.allowed_origins = ["https://resque.example.com"]
47
+
48
+ # Optional: escape hatch for mcp settings this gem doesn't expose directly,
49
+ # forwarded to the transport (the DNS-rebinding keys above always take
50
+ # precedence and can't be weakened here):
51
+ # c.mcp_transport_options = { max_request_bytes: 8 * 1024 * 1024 }
52
+
53
+ # Optional: which job-args keys to mask as [FILTERED] in tool responses.
54
+ # Defaults to your app's config.filter_parameters; an explicit list
55
+ # replaces it (merge yourself if you want both):
56
+ # c.filter_parameters = Rails.application.config.filter_parameters + [:iban]
32
57
  end
33
58
  ```
34
59
 
35
- 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.
60
+ The token is **required** — the endpoint answers `503` until one is configured, and `401` on any request without a matching `Authorization: Bearer` header. The endpoint also validates the `Host`/`Origin` headers against DNS rebinding: the allowed hosts default to your Rails `config.hosts`, so a non-loopback request gets `403` unless its Host is in that list (or in an explicit `allowed_hosts`). The engine talks to whatever `Resque.redis` your app already configured; it never opens its own Redis connection.
61
+
62
+ 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.
63
+
64
+ 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.
36
65
 
37
66
  Connect Claude Code:
38
67
 
@@ -45,7 +74,7 @@ Then ask, e.g., "How is my Resque doing?"
45
74
 
46
75
  ## Tools
47
76
 
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.
77
+ 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
78
 
50
79
  ### `overview` — read-only
51
80
 
@@ -82,6 +111,30 @@ List all queues with sizes, or inspect a single queue. With `queue` and `include
82
111
 
83
112
  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
113
 
114
+ ### `worker_stats` — read-only
115
+
116
+ List registered workers with their current state, optionally filtered by `state` (`working` / `idle` / `all`):
117
+
118
+ ```json
119
+ {
120
+ "workers": [
121
+ {
122
+ "id": "worker-host-1:4021:imports,default", "state": "working",
123
+ "queues": ["imports", "default"], "started": "2026-06-30 04:12:09 UTC",
124
+ "processed": 48211, "failed": 12, "heartbeat_expired": false,
125
+ "current_job": { "queue": "imports", "class": "ImportWorker",
126
+ "args_preview": "[812, …]", "run_at": "2026-07-02T09:14:55Z" }
127
+ }
128
+ ],
129
+ "counts": { "total": 12, "working": 3, "idle": 9, "heartbeat_expired": 1 },
130
+ "page": { "total": 12, "offset": 0, "limit": 50, "returned": 12, "has_more": false, "next_offset": null },
131
+ "meta": { "…": "…" }
132
+ }
133
+ ```
134
+
135
+ - `heartbeat_expired: true` flags a worker whose last heartbeat is older than `Resque.prune_interval` — it is likely dead and its record stale.
136
+ - `counts` always covers all workers; a `state` filter only narrows the `workers` list (and its `page` envelope).
137
+
85
138
  ### `list_failures` — read-only
86
139
 
87
140
  Page through failed jobs, newest first, as compact records (truncated error, args preview, no backtrace). Optionally filter by `class_name`.
@@ -7,9 +7,17 @@ module Resque
7
7
 
8
8
  def handle
9
9
  server = ServerFactory.build(environment: Rails.env.to_s)
10
- transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
11
- server, stateless: true, enable_json_response: true
10
+ config = Resque::Mcp.config
11
+ # Passthrough first; our security-critical keys override, so nothing
12
+ # in mcp_transport_options can weaken the DNS-rebinding posture.
13
+ options = config.mcp_transport_options.merge(
14
+ stateless: true,
15
+ enable_json_response: true,
16
+ dns_rebinding_protection: true,
17
+ allowed_hosts: config.allowed_hosts,
18
+ allowed_origins: config.allowed_origins
12
19
  )
20
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **options)
13
21
 
14
22
  status, headers, body = transport.handle_request(request)
15
23
  headers.each { |key, value| response.set_header(key, value) }
@@ -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,62 @@ module Resque
4
4
  module Mcp
5
5
  class Configuration
6
6
  attr_accessor :auth_token
7
+
8
+ # DNS-rebinding protection (mcp >= 0.23, CVE-2026-63118). The transport
9
+ # validates Host/Origin headers; loopback hosts are always allowed.
10
+ #
11
+ # nil (default) inherits the host's Rails config.hosts; an explicit list
12
+ # replaces it; [] means loopback only. Either way, only plain hostname
13
+ # strings are honored — regexps, IPAddrs, and leading-dot subdomain
14
+ # wildcards (".example.com") can't map to the SDK's exact-hostname
15
+ # matching (and a non-string would crash its downcase), so they're
16
+ # dropped from both paths. allowed_origins adds extra permitted Origin
17
+ # values beyond same-origin.
18
+ attr_writer :allowed_hosts, :allowed_origins
19
+
20
+ # Internal seam: a callable returning the host's config.hosts, read
21
+ # lazily (the engine wires it) so post-boot changes are never missed.
22
+ attr_accessor :default_allowed_hosts
23
+
24
+ def allowed_hosts
25
+ list = (defined?(@allowed_hosts) && @allowed_hosts) ? @allowed_hosts : default_allowed_hosts&.call
26
+ Array(list).select { |h| h.is_a?(String) && !h.start_with?(".") }
27
+ end
28
+
29
+ def allowed_origins
30
+ @allowed_origins || []
31
+ end
32
+
33
+ # Extra settings for the mcp gem, forwarded verbatim to its
34
+ # StreamableHTTPTransport (e.g. max_request_bytes:). Tuning only — the
35
+ # controller applies the security-critical keys (allowed_hosts,
36
+ # allowed_origins, stateless, dns_rebinding_protection) *after* these,
37
+ # so a passthrough value can never weaken them.
38
+ attr_writer :mcp_transport_options
39
+
40
+ def mcp_transport_options
41
+ @mcp_transport_options || {}
42
+ end
43
+
44
+ # nil = inherit the host default (the engine wires Rails'
45
+ # filter_parameters as a lazy source); an explicit list replaces it
46
+ # ([] disables filtering).
47
+ attr_accessor :filter_parameters
48
+ # Internal seam: a callable returning the host's filter list, read
49
+ # lazily so late additions to Rails' list (or other engines'
50
+ # after_initialize hooks) are never missed.
51
+ attr_accessor :default_filter_parameters
52
+
53
+ # Memoized against the *effective* list, so both reassignment and
54
+ # in-place mutation (config.filter_parameters << :iban) take effect.
55
+ def param_filter
56
+ list = filter_parameters || default_filter_parameters&.call || []
57
+ unless defined?(@param_filter) && @param_filter_list == list
58
+ @param_filter_list = list.dup
59
+ @param_filter = ActiveSupport::ParameterFilter.new(list)
60
+ end
61
+ @param_filter
62
+ end
7
63
  end
8
64
  end
9
65
  end
@@ -6,6 +6,21 @@ 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
17
+
18
+ # allowed_hosts inherits the host's config.hosts (the same list Rails'
19
+ # own host authorization uses) unless explicitly configured, read
20
+ # lazily for the same reason as the filter list above.
21
+ initializer "resque_mcp.allowed_hosts" do
22
+ Resque::Mcp.config.default_allowed_hosts = -> { Rails.application.config.hosts }
23
+ end
9
24
  end
10
25
  end
11
26
  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.4.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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josch Bockler
@@ -33,16 +33,36 @@ dependencies:
33
33
  name: mcp
34
34
  requirement: !ruby/object:Gem::Requirement
35
35
  requirements:
36
- - - "~>"
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.23'
39
+ - - "<"
37
40
  - !ruby/object:Gem::Version
38
- version: '0.22'
41
+ version: '1'
39
42
  type: :runtime
40
43
  prerelease: false
41
44
  version_requirements: !ruby/object:Gem::Requirement
42
45
  requirements:
43
- - - "~>"
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.23'
49
+ - - "<"
44
50
  - !ruby/object:Gem::Version
45
- version: '0.22'
51
+ version: '1'
52
+ - !ruby/object:Gem::Dependency
53
+ name: activesupport
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.2'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '7.2'
46
66
  - !ruby/object:Gem::Dependency
47
67
  name: railties
48
68
  requirement: !ruby/object:Gem::Requirement
@@ -90,12 +110,16 @@ files:
90
110
  - lib/resque/mcp/adapter.rb
91
111
  - lib/resque/mcp/configuration.rb
92
112
  - lib/resque/mcp/engine.rb
113
+ - lib/resque/mcp/models/failure.rb
114
+ - lib/resque/mcp/models/job.rb
115
+ - lib/resque/mcp/models/worker.rb
93
116
  - lib/resque/mcp/server_factory.rb
94
117
  - lib/resque/mcp/tools/base.rb
95
118
  - lib/resque/mcp/tools/get_failure.rb
96
119
  - lib/resque/mcp/tools/list_failures.rb
97
120
  - lib/resque/mcp/tools/overview.rb
98
121
  - lib/resque/mcp/tools/queue_stats.rb
122
+ - lib/resque/mcp/tools/worker_stats.rb
99
123
  - lib/resque/mcp/version.rb
100
124
  - sig/resque/mcp.rbs
101
125
  homepage: https://github.com/jbockler/resque-mcp