resque-mcp 0.3.0 → 0.5.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 +13 -0
- data/README.md +17 -1
- data/app/controllers/resque/mcp/endpoint_controller.rb +75 -9
- data/lib/resque/mcp/configuration.rb +37 -0
- data/lib/resque/mcp/engine.rb +7 -0
- data/lib/resque/mcp/server_factory.rb +4 -0
- data/lib/resque/mcp/version.rb +1 -1
- metadata +11 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa2476b4d89a552651fd9fefbde7ebb001f75fbf684fa5182388c39cd501bcc5
|
|
4
|
+
data.tar.gz: bf79be53a377e49347c692bb78d2f2519e03756b63d07311bf473601414305ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75df4bb783023d55034a99235970952ea70f931fea145b3cebd48efa7404654c982d65a604ec29ee52086c3b3bbffae9f360eca7acc44e594a2d44a368bf9c40
|
|
7
|
+
data.tar.gz: a182d35074d8e72bc0f638cff1793405b37427093750810f0061f750c57f0adce34974d1e970cfcb1e4fd58f4f6663d659880bdd80f486e4fc42ecf07ae3a679
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.0]
|
|
6
|
+
|
|
7
|
+
- MCP SDK 1.x support: the `mcp` dependency widens to `>= 0.23, < 2`, admitting the SDK's API-stable 1.x line. No configuration changes; existing deployments behave as before.
|
|
8
|
+
- Sessionless 2026-07-28 lifecycle (SEP-2575, needs `mcp >= 1.2`): clients may skip the `initialize` handshake and carry the protocol version per request, and discover the server via `server/discover`. Older `mcp` versions keep serving the handshake lifecycle unchanged.
|
|
9
|
+
- Capabilities are now declared explicitly instead of taking the SDK's default, dropping the `listChanged`/`subscribe` notification flags a per-request stateless endpoint cannot push. Empty `prompts`/`resources` capabilities stay declared, so `prompts/list` and `resources/list` keep answering with empty lists.
|
|
10
|
+
- `subscriptions/listen` is declined with a correlatable JSON-RPC `-32601` error instead of an opaque `500`: a per-request stateless server has no notification stream to open. The refusal ships with HTTP 200 so clients that only parse successful responses still see it.
|
|
11
|
+
|
|
12
|
+
## [0.4.0]
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
5
18
|
## [0.3.0]
|
|
6
19
|
|
|
7
20
|
- `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.
|
data/README.md
CHANGED
|
@@ -34,6 +34,22 @@ Resque::Mcp.configure do |c|
|
|
|
34
34
|
# token could be created by e.g.: `bin/rails runner 'puts SecureRandom.base58(32)'`
|
|
35
35
|
c.auth_token = Rails.application.credentials.dig(:resque_mcp, :token)
|
|
36
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
|
+
|
|
37
53
|
# Optional: which job-args keys to mask as [FILTERED] in tool responses.
|
|
38
54
|
# Defaults to your app's config.filter_parameters; an explicit list
|
|
39
55
|
# replaces it (merge yourself if you want both):
|
|
@@ -41,7 +57,7 @@ Resque::Mcp.configure do |c|
|
|
|
41
57
|
end
|
|
42
58
|
```
|
|
43
59
|
|
|
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.
|
|
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.
|
|
45
61
|
|
|
46
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.
|
|
47
63
|
|
|
@@ -6,27 +6,93 @@ module Resque
|
|
|
6
6
|
before_action :require_auth_token, only: :handle
|
|
7
7
|
|
|
8
8
|
def handle
|
|
9
|
+
# Rack 3 input may not be rewindable: read before the transport does.
|
|
10
|
+
@raw_jsonrpc_body = request.raw_post
|
|
9
11
|
server = ServerFactory.build(environment: Rails.env.to_s)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
config = Resque::Mcp.config
|
|
13
|
+
# Passthrough first; our security-critical keys override, so nothing
|
|
14
|
+
# in mcp_transport_options can weaken the DNS-rebinding posture.
|
|
15
|
+
options = config.mcp_transport_options.merge(
|
|
16
|
+
stateless: true,
|
|
17
|
+
enable_json_response: true,
|
|
18
|
+
dns_rebinding_protection: true,
|
|
19
|
+
allowed_hosts: config.allowed_hosts,
|
|
20
|
+
allowed_origins: config.allowed_origins
|
|
12
21
|
)
|
|
22
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **options)
|
|
13
23
|
|
|
14
24
|
status, headers, body = transport.handle_request(request)
|
|
15
|
-
|
|
25
|
+
render_transport_response(status, headers, body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def method_not_allowed
|
|
29
|
+
head :method_not_allowed
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Rack 3 forbids these on a response; the SSE headers carry one.
|
|
35
|
+
HOP_BY_HOP_HEADERS = %w[
|
|
36
|
+
connection keep-alive proxy-authenticate proxy-authorization
|
|
37
|
+
te trailer transfer-encoding upgrade
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# A callable body is a stream the transport wants to keep open (today
|
|
41
|
+
# only `subscriptions/listen`); stateless has nothing to push, so decline.
|
|
42
|
+
def render_transport_response(status, headers, body)
|
|
43
|
+
if body.respond_to?(:call)
|
|
44
|
+
# 200, not 501: clients that reject on `!response.ok` never parse the
|
|
45
|
+
# body, and the transport headers describe the declined stream.
|
|
46
|
+
return render json: {
|
|
47
|
+
jsonrpc: "2.0",
|
|
48
|
+
id: jsonrpc_request_id,
|
|
49
|
+
error: {
|
|
50
|
+
code: -32601,
|
|
51
|
+
message: "Method not found: #{jsonrpc_method_label} is not supported by this stateless endpoint"
|
|
52
|
+
}
|
|
53
|
+
}, status: :ok
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
headers.each do |key, value|
|
|
57
|
+
next if HOP_BY_HOP_HEADERS.include?(key.to_s.downcase)
|
|
58
|
+
response.set_header(key, value)
|
|
59
|
+
end
|
|
16
60
|
|
|
17
61
|
payload = body.first
|
|
18
|
-
|
|
19
|
-
|
|
62
|
+
return head status unless payload
|
|
63
|
+
|
|
64
|
+
content_type = transport_content_type(headers)
|
|
65
|
+
if content_type && !content_type.start_with?("application/json")
|
|
66
|
+
render body: payload, content_type: content_type, status: status
|
|
20
67
|
else
|
|
21
|
-
|
|
68
|
+
render json: payload, status: status
|
|
22
69
|
end
|
|
23
70
|
end
|
|
24
71
|
|
|
25
|
-
|
|
26
|
-
|
|
72
|
+
# A null id leaves a strict client unable to correlate the error.
|
|
73
|
+
def jsonrpc_payload
|
|
74
|
+
@jsonrpc_payload ||= begin
|
|
75
|
+
parsed = JSON.parse(@raw_jsonrpc_body.to_s)
|
|
76
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
27
80
|
end
|
|
28
81
|
|
|
29
|
-
|
|
82
|
+
def jsonrpc_request_id
|
|
83
|
+
id = jsonrpc_payload["id"]
|
|
84
|
+
id if id.is_a?(String) || id.is_a?(Integer)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def jsonrpc_method_label
|
|
88
|
+
method_name = jsonrpc_payload["method"]
|
|
89
|
+
method_name.is_a?(String) ? method_name : "this method"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def transport_content_type(headers)
|
|
93
|
+
_, value = headers.find { |key, _| key.to_s.casecmp("content-type").zero? }
|
|
94
|
+
value
|
|
95
|
+
end
|
|
30
96
|
|
|
31
97
|
# No reliable boot-time hook exists (initializer ordering), so a
|
|
32
98
|
# missing token is caught per request: 503, never silently open.
|
|
@@ -4,6 +4,43 @@ 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
|
+
|
|
7
44
|
# nil = inherit the host default (the engine wires Rails'
|
|
8
45
|
# filter_parameters as a lazy source); an explicit list replaces it
|
|
9
46
|
# ([] disables filtering).
|
data/lib/resque/mcp/engine.rb
CHANGED
|
@@ -14,6 +14,13 @@ module Resque
|
|
|
14
14
|
initializer "resque_mcp.filter_parameters" do
|
|
15
15
|
Resque::Mcp.config.default_filter_parameters = -> { Rails.application.config.filter_parameters }
|
|
16
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
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|
|
@@ -10,6 +10,10 @@ module Resque
|
|
|
10
10
|
name: "resque-mcp",
|
|
11
11
|
version: Resque::Mcp::VERSION,
|
|
12
12
|
tools: [Tools::Overview, Tools::QueueStats, Tools::WorkerStats, Tools::ListFailures, Tools::GetFailure],
|
|
13
|
+
# Explicit, or the SDK advertises `listChanged`/`subscribe` streams
|
|
14
|
+
# we decline. This hash replaces the defaults and the SDK refuses
|
|
15
|
+
# methods whose key is absent — hence the empty prompts/resources.
|
|
16
|
+
capabilities: {tools: {}, prompts: {}, resources: {}, logging: {}},
|
|
13
17
|
server_context: {adapter: Adapter.new, environment: environment}
|
|
14
18
|
)
|
|
15
19
|
end
|
data/lib/resque/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: resque-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Josch Bockler
|
|
@@ -33,16 +33,22 @@ 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: '
|
|
41
|
+
version: '2'
|
|
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: '
|
|
51
|
+
version: '2'
|
|
46
52
|
- !ruby/object:Gem::Dependency
|
|
47
53
|
name: activesupport
|
|
48
54
|
requirement: !ruby/object:Gem::Requirement
|