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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +40 -3
- data/lib/resque/mcp/adapter.rb +39 -7
- data/lib/resque/mcp/configuration.rb +19 -0
- data/lib/resque/mcp/engine.rb +8 -0
- data/lib/resque/mcp/models/failure.rb +16 -0
- data/lib/resque/mcp/models/job.rb +68 -0
- data/lib/resque/mcp/models/worker.rb +18 -0
- data/lib/resque/mcp/server_factory.rb +1 -1
- data/lib/resque/mcp/tools/base.rb +6 -3
- data/lib/resque/mcp/tools/get_failure.rb +10 -10
- data/lib/resque/mcp/tools/list_failures.rb +8 -8
- data/lib/resque/mcp/tools/queue_stats.rb +1 -1
- data/lib/resque/mcp/tools/worker_stats.rb +58 -0
- data/lib/resque/mcp/version.rb +1 -1
- data/lib/resque/mcp.rb +1 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 890b5b8eb96c160226ce467a2a626861f25e774e5cd80402a4e8256a5aa361f1
|
|
4
|
+
data.tar.gz: fb3e64270e1d0dd2fca69d4ecedbd9c06b72ff146dd6160b5fc305420bc49117
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](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
|
-
|
|
15
|
+
Add the gem to your application's Gemfile:
|
|
14
16
|
|
|
15
17
|
```ruby
|
|
16
18
|
# Gemfile
|
|
17
|
-
gem "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 (
|
|
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`.
|
data/lib/resque/mcp/adapter.rb
CHANGED
|
@@ -58,7 +58,10 @@ module Resque
|
|
|
58
58
|
|
|
59
59
|
def peek(queue, offset:, limit:)
|
|
60
60
|
ensure_known_queue!(queue)
|
|
61
|
-
|
|
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
|
-
|
|
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
|
data/lib/resque/mcp/engine.rb
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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(
|
|
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
|
|
22
|
+
backtrace, omitted = capped_backtrace(record.backtrace)
|
|
23
23
|
success_response({
|
|
24
|
-
index: record
|
|
25
|
-
failed_at: record
|
|
26
|
-
queue: record
|
|
27
|
-
class: record
|
|
28
|
-
args: full_args(record
|
|
29
|
-
exception: record
|
|
30
|
-
error: record
|
|
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
|
|
34
|
-
retried_at: record
|
|
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
|
|
32
|
-
failed_at: record
|
|
33
|
-
queue: record
|
|
34
|
-
class: record
|
|
35
|
-
args_preview: args_preview(record
|
|
36
|
-
exception: record
|
|
37
|
-
error: truncated_error(record
|
|
38
|
-
retried_at: record
|
|
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
|
|
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
|
data/lib/resque/mcp/version.rb
CHANGED
data/lib/resque/mcp.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.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
|