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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 890b5b8eb96c160226ce467a2a626861f25e774e5cd80402a4e8256a5aa361f1
4
- data.tar.gz: fb3e64270e1d0dd2fca69d4ecedbd9c06b72ff146dd6160b5fc305420bc49117
3
+ metadata.gz: aa2476b4d89a552651fd9fefbde7ebb001f75fbf684fa5182388c39cd501bcc5
4
+ data.tar.gz: bf79be53a377e49347c692bb78d2f2519e03756b63d07311bf473601414305ec
5
5
  SHA512:
6
- metadata.gz: d066b59609ab3e12a1c0e7458bbf9503751b94f548c858e51aeb5a1cd921aa0675f1643d6d64c2bf83cc524314961104f9d11b5c25ec874731e5726f29799edf
7
- data.tar.gz: 5e84b9b0249ee94416e9c505abba4637f01d8669dec570bab97a4bedb556a6008497525b4d0f19233c5b002f9228aede5c2ee8ae5e1467ae7a5c17d14d458c32
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
- transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
11
- server, stateless: true, enable_json_response: true
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
- headers.each { |key, value| response.set_header(key, value) }
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
- if payload
19
- render json: payload, status: status
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
- head status
68
+ render json: payload, status: status
22
69
  end
23
70
  end
24
71
 
25
- def method_not_allowed
26
- head :method_not_allowed
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
- private
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).
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Resque
4
4
  module Mcp
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
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.3.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: '0.22'
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: '0.22'
51
+ version: '2'
46
52
  - !ruby/object:Gem::Dependency
47
53
  name: activesupport
48
54
  requirement: !ruby/object:Gem::Requirement