pgbus 0.9.4 → 0.9.5
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/README.md +115 -0
- data/app/helpers/pgbus/application_helper.rb +12 -2
- data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
- data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
- data/config/locales/da.yml +1 -0
- data/config/locales/de.yml +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/locales/fi.yml +1 -0
- data/config/locales/fr.yml +1 -0
- data/config/locales/it.yml +1 -0
- data/config/locales/ja.yml +1 -0
- data/config/locales/nb.yml +1 -0
- data/config/locales/nl.yml +1 -0
- data/config/locales/pt.yml +1 -0
- data/config/locales/sv.yml +1 -0
- data/lib/pgbus/cli.rb +16 -0
- data/lib/pgbus/client.rb +34 -7
- data/lib/pgbus/configuration.rb +16 -0
- data/lib/pgbus/mcp/base_tool.rb +79 -0
- data/lib/pgbus/mcp/health_analyzer.rb +159 -0
- data/lib/pgbus/mcp/rack_app.rb +97 -0
- data/lib/pgbus/mcp/redactor.rb +72 -0
- data/lib/pgbus/mcp/runner.rb +73 -0
- data/lib/pgbus/mcp/server.rb +62 -0
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
- data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
- data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
- data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
- data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
- data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
- data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
- data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
- data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
- data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
- data/lib/pgbus/mcp.rb +55 -0
- data/lib/pgbus/process/heartbeat.rb +8 -2
- data/lib/pgbus/process/supervisor.rb +44 -0
- data/lib/pgbus/process/worker.rb +12 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +19 -2
- data/lib/pgbus.rb +15 -1
- metadata +20 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# Computes the top-level pgbus health verdict (OK / DEGRADED / STALLED)
|
|
6
|
+
# from the existing DataSource read layer. This is the single signal that
|
|
7
|
+
# catches the silent-worker-wedge class of incident (#179, #174, #181):
|
|
8
|
+
# a queue with visible messages and no claim progress while a subscribing
|
|
9
|
+
# worker is heart-beating with idle capacity.
|
|
10
|
+
#
|
|
11
|
+
# Verdict semantics (issue #180 acceptance criteria):
|
|
12
|
+
# STALLED — backlog (visible > 0) AND at least one worker is heart-beating
|
|
13
|
+
# but its claim loop has stopped advancing (status :stalled),
|
|
14
|
+
# OR backlog with live-but-idle workers and zero claim progress.
|
|
15
|
+
# DEGRADED — something is wrong but not the wedge: stale processes, a
|
|
16
|
+
# paused queue holding a backlog, growing DLQ, or MVCC horizon
|
|
17
|
+
# pinned by a long-running transaction.
|
|
18
|
+
# OK — draining normally / nothing actionable.
|
|
19
|
+
class HealthAnalyzer
|
|
20
|
+
# A worker is considered to have idle capacity unless its metadata
|
|
21
|
+
# explicitly reports it is saturated. We treat the presence of any live
|
|
22
|
+
# worker as "has capacity" because a wedged worker reports healthy
|
|
23
|
+
# heartbeats while doing no work — exactly the case we must catch.
|
|
24
|
+
WORKER_KIND = "worker"
|
|
25
|
+
|
|
26
|
+
def initialize(data_source)
|
|
27
|
+
@data_source = data_source
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns a machine-readable verdict hash suitable for both interactive
|
|
31
|
+
# agent use and automated alerting.
|
|
32
|
+
def verdict
|
|
33
|
+
queues = @data_source.queues_with_metrics
|
|
34
|
+
processes = @data_source.processes
|
|
35
|
+
health, health_error = safe_queue_health
|
|
36
|
+
|
|
37
|
+
# Partition queues once: non-DLQ (the operational set) and the subset
|
|
38
|
+
# of those with visible, claimable backlog. Paused queues are removed
|
|
39
|
+
# from the STALLED backlog (an intentional pause is not the wedge —
|
|
40
|
+
# it's reported under DEGRADED), but kept in `non_dlq` for the summary.
|
|
41
|
+
non_dlq = queues.reject { |q| dlq?(q) }
|
|
42
|
+
backlog = non_dlq.select { |q| q[:queue_visible_length].to_i.positive? }
|
|
43
|
+
active_backlog = backlog.reject { |q| q[:paused] }
|
|
44
|
+
|
|
45
|
+
stalled = stalled_reasons(active_backlog, processes)
|
|
46
|
+
degraded = degraded_reasons(queues, backlog, processes, health, health_error)
|
|
47
|
+
|
|
48
|
+
status = if stalled.any?
|
|
49
|
+
"STALLED"
|
|
50
|
+
elsif degraded.any?
|
|
51
|
+
"DEGRADED"
|
|
52
|
+
else
|
|
53
|
+
"OK"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
status: status,
|
|
58
|
+
reasons: stalled + degraded,
|
|
59
|
+
checked_at: Time.now.utc.iso8601,
|
|
60
|
+
summary: build_summary(queues, non_dlq, processes, health)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def dlq?(queue)
|
|
67
|
+
queue[:name].to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns [health_hash, error_or_nil]. We never raise — pgbus_health
|
|
71
|
+
# must always produce a verdict — but we DO surface the failure so the
|
|
72
|
+
# caller knows the verdict was built from partial data, and we log it
|
|
73
|
+
# for operators (per the project's no-silent-errors rule).
|
|
74
|
+
def safe_queue_health
|
|
75
|
+
[@data_source.queue_health_stats, nil]
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Pgbus.logger.warn { "[Pgbus::MCP] queue_health_stats failed: #{e.class}: #{e.message}" }
|
|
78
|
+
[{}, e]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# STALLED detection: an active (non-paused) backed-up queue while a
|
|
82
|
+
# worker is in the :stalled state, or backed up with live-but-idle
|
|
83
|
+
# workers present.
|
|
84
|
+
def stalled_reasons(backlog, processes)
|
|
85
|
+
return [] if backlog.empty?
|
|
86
|
+
|
|
87
|
+
workers = processes.select { |p| p[:kind] == WORKER_KIND }
|
|
88
|
+
return [] if workers.empty?
|
|
89
|
+
|
|
90
|
+
stalled_workers = workers.select { |w| w[:status].to_s == "stalled" }
|
|
91
|
+
live_workers = workers.select { |w| %w[healthy stalled].include?(w[:status].to_s) }
|
|
92
|
+
|
|
93
|
+
reasons = []
|
|
94
|
+
if stalled_workers.any?
|
|
95
|
+
reasons << "#{stalled_workers.size} worker(s) stalled (heart-beating but claim loop not advancing) " \
|
|
96
|
+
"while #{backlog.size} queue(s) have visible backlog: #{backlog_names(backlog)}"
|
|
97
|
+
elsif live_workers.any? && all_unread?(backlog)
|
|
98
|
+
reasons << "#{backlog.size} queue(s) have visible messages with read_ct=0 (never claimed) " \
|
|
99
|
+
"while #{live_workers.size} worker(s) are alive: #{backlog_names(backlog)}"
|
|
100
|
+
end
|
|
101
|
+
reasons
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# DEGRADED detection: conditions worth surfacing that are not the wedge.
|
|
105
|
+
def degraded_reasons(queues, backlog, processes, health, health_error)
|
|
106
|
+
reasons = []
|
|
107
|
+
|
|
108
|
+
reasons << "queue health stats unavailable: #{health_error.class}: #{health_error.message}" if health_error
|
|
109
|
+
|
|
110
|
+
stale = processes.select { |p| p[:status].to_s == "stale" }
|
|
111
|
+
reasons << "#{stale.size} process(es) stale (no recent heartbeat)" if stale.any?
|
|
112
|
+
|
|
113
|
+
paused_backlog = backlog.select { |q| q[:paused] }
|
|
114
|
+
reasons << "#{paused_backlog.size} paused queue(s) holding a backlog: #{backlog_names(paused_backlog)}" if paused_backlog.any?
|
|
115
|
+
|
|
116
|
+
dlq = queues.select { |q| dlq?(q) && q[:queue_length].to_i.positive? }
|
|
117
|
+
reasons << "#{dlq.size} dead-letter queue(s) hold messages" if dlq.any?
|
|
118
|
+
|
|
119
|
+
age = health[:oldest_transaction_age_sec]
|
|
120
|
+
reasons << "oldest open transaction is #{age}s old (MVCC horizon pinning risk)" if age && age > 300
|
|
121
|
+
|
|
122
|
+
reasons
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The strongest wedge signal: visible messages that have never been read.
|
|
126
|
+
# When queue metrics don't expose read_ct we conservatively treat the
|
|
127
|
+
# backlog as unread (the wedge default) so we don't miss the condition.
|
|
128
|
+
def all_unread?(backlog)
|
|
129
|
+
backlog.all? { |q| !q.key?(:max_read_ct) || q[:max_read_ct].to_i.zero? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def backlog_names(queues)
|
|
133
|
+
queues.map { |q| q[:name] }.join(", ")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_summary(queues, non_dlq, processes, health)
|
|
137
|
+
workers = stale = stalled = 0
|
|
138
|
+
processes.each do |p|
|
|
139
|
+
status = p[:status].to_s
|
|
140
|
+
stale += 1 if status == "stale"
|
|
141
|
+
next unless p[:kind] == WORKER_KIND
|
|
142
|
+
|
|
143
|
+
workers += 1
|
|
144
|
+
stalled += 1 if status == "stalled"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
queues: queues.size,
|
|
149
|
+
total_visible: non_dlq.sum { |q| q[:queue_visible_length].to_i },
|
|
150
|
+
total_depth: non_dlq.sum { |q| q[:queue_length].to_i },
|
|
151
|
+
workers: workers,
|
|
152
|
+
stalled_workers: stalled,
|
|
153
|
+
stale_processes: stale,
|
|
154
|
+
oldest_transaction_age_sec: health[:oldest_transaction_age_sec]
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# A turnkey, gated Rack app that serves the read-only pgbus diagnostic MCP
|
|
6
|
+
# server over HTTP. Mount it inside your existing Rails app — no second
|
|
7
|
+
# process, same DB credentials, same connection pool:
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb
|
|
10
|
+
# mount Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"]) => "/pgbus/mcp"
|
|
11
|
+
#
|
|
12
|
+
# The underlying transport runs in stateless + JSON-response mode, which:
|
|
13
|
+
# * makes every request a self-contained POST returning a single JSON
|
|
14
|
+
# object (no in-memory session, no SSE stream, no reaper thread), so it
|
|
15
|
+
# is safe behind multiple Puma/Falcon workers — any worker can answer
|
|
16
|
+
# any request;
|
|
17
|
+
# * is exactly what a read-only diagnostic server needs (it never pushes
|
|
18
|
+
# server-initiated messages to the client).
|
|
19
|
+
#
|
|
20
|
+
# Security: requests are rejected with 401 unless they carry the configured
|
|
21
|
+
# token (or pass the supplied auth callable). Run it on an internal network
|
|
22
|
+
# / behind your VPN, never internet-exposed.
|
|
23
|
+
class RackApp
|
|
24
|
+
BEARER_PREFIX = "Bearer "
|
|
25
|
+
UNAUTHORIZED = [
|
|
26
|
+
401,
|
|
27
|
+
{ "Content-Type" => "application/json", "WWW-Authenticate" => "Bearer" },
|
|
28
|
+
[{ jsonrpc: "2.0", id: nil, error: { code: -32_001, message: "Unauthorized" } }.to_json]
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# @param data_source [Pgbus::Web::DataSource] read layer the tools query.
|
|
32
|
+
# Built once and shared: DataSource acquires Pgbus::BusRecord.connection
|
|
33
|
+
# from the ActiveRecord pool on each call, so a single instance is
|
|
34
|
+
# request-safe across threads/workers.
|
|
35
|
+
# @param allow_payloads [Boolean] when true, tools honor a per-call
|
|
36
|
+
# include_payloads flag; otherwise message bodies are always redacted.
|
|
37
|
+
# @param token [String, nil] shared secret. When present, requests must
|
|
38
|
+
# send `Authorization: Bearer <token>`. nil disables the built-in gate.
|
|
39
|
+
# @param auth [#call, nil] custom authenticator taking a Rack::Request and
|
|
40
|
+
# returning truthy to allow. Mirrors Pgbus.configuration.web_auth. Takes
|
|
41
|
+
# precedence over +token+ when both are given.
|
|
42
|
+
def initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
|
|
43
|
+
@token = token
|
|
44
|
+
@auth = auth
|
|
45
|
+
@server = Server.build(data_source: data_source, allow_payloads: allow_payloads)
|
|
46
|
+
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
47
|
+
@server, stateless: true, enable_json_response: true
|
|
48
|
+
)
|
|
49
|
+
warn_unauthenticated! if @token.nil? && @auth.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Mount THIS object, never the bare transport. The auth gate lives here
|
|
53
|
+
# and runs before handle_request for *every* HTTP method (POST/GET/
|
|
54
|
+
# DELETE/...). Mounting @transport directly would skip authorization —
|
|
55
|
+
# the gem's transport has no auth of its own.
|
|
56
|
+
def call(env)
|
|
57
|
+
request = Rack::Request.new(env)
|
|
58
|
+
return UNAUTHORIZED unless authorized?(request)
|
|
59
|
+
|
|
60
|
+
@transport.handle_request(request)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# An explicit auth callable wins; otherwise fall back to the bearer token;
|
|
66
|
+
# otherwise (neither configured) allow — the constructor already warned.
|
|
67
|
+
def authorized?(request)
|
|
68
|
+
return !!@auth.call(request) if @auth
|
|
69
|
+
return bearer_matches?(request) if @token
|
|
70
|
+
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bearer_matches?(request)
|
|
75
|
+
header = request.get_header("HTTP_AUTHORIZATION").to_s
|
|
76
|
+
return false unless header.start_with?(BEARER_PREFIX)
|
|
77
|
+
|
|
78
|
+
Runner.secure_compare?(@token, header.delete_prefix(BEARER_PREFIX))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def warn_unauthenticated!
|
|
82
|
+
Pgbus.logger.warn do
|
|
83
|
+
"[Pgbus::MCP] HTTP diagnostic server mounted without authentication. " \
|
|
84
|
+
"Pass token: or auth: to Pgbus::MCP.rack_app, and keep it on an internal network."
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
# Build a gated Rack app serving the read-only diagnostic tools over HTTP.
|
|
92
|
+
# See {RackApp} for the parameters and deployment guidance.
|
|
93
|
+
def rack_app(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
|
|
94
|
+
RackApp.new(data_source: data_source, allow_payloads: allow_payloads, token: token, auth: auth)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# Strips message bodies / headers / job arguments from tool responses
|
|
6
|
+
# before they leave the process. Job payloads can carry PII or secrets, so
|
|
7
|
+
# the diagnostic tools return metadata (ids, counts, ages, read_ct, vt,
|
|
8
|
+
# queue names, job_class) by default and only include the raw payload when
|
|
9
|
+
# an explicit, separately-gated flag is set.
|
|
10
|
+
#
|
|
11
|
+
# +deep_redact+ is applied centrally at the response boundary
|
|
12
|
+
# (BaseTool.json_response), so redaction is fail-safe: a tool author cannot
|
|
13
|
+
# leak a payload by forgetting to redact — they have to opt in.
|
|
14
|
+
#
|
|
15
|
+
# See issue #180: "No sensitive payload leakage — diagnostic tools should
|
|
16
|
+
# return metadata ... and redact or omit message payloads unless an
|
|
17
|
+
# explicit, separately-gated flag is set."
|
|
18
|
+
module Redactor
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Keys whose values are message bodies / headers and must never be
|
|
22
|
+
# returned unless payloads are explicitly allowed.
|
|
23
|
+
PAYLOAD_KEYS = %i[message headers payload arguments].freeze
|
|
24
|
+
|
|
25
|
+
# Replacement marker so the agent can see a field exists but was hidden,
|
|
26
|
+
# rather than the field silently vanishing.
|
|
27
|
+
REDACTED = "[redacted]"
|
|
28
|
+
|
|
29
|
+
# True if +key+ names a payload-bearing field. Tolerates string or symbol
|
|
30
|
+
# keys and never raises on a non-symbolizable key.
|
|
31
|
+
def payload_key?(key)
|
|
32
|
+
PAYLOAD_KEYS.include?(key.respond_to?(:to_sym) ? key.to_sym : key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Recursively redact payload-bearing keys anywhere in a nested structure
|
|
36
|
+
# of hashes and arrays. This is the fail-safe applied at the response
|
|
37
|
+
# boundary (BaseTool.json_response): a tool that returns a payload key —
|
|
38
|
+
# at any depth, even one its author forgot about — has it stripped unless
|
|
39
|
+
# payloads are explicitly allowed for the call.
|
|
40
|
+
#
|
|
41
|
+
# A payload key is redacted only when its value is a leaf (the serialized
|
|
42
|
+
# message body / headers string). When the value is a Hash or Array the
|
|
43
|
+
# key is an envelope (e.g. a {message: {...}} detail wrapper, not a raw
|
|
44
|
+
# body), so we descend into it instead of blanking the whole subtree.
|
|
45
|
+
def deep_redact(value, include_payloads: false)
|
|
46
|
+
return value if include_payloads
|
|
47
|
+
|
|
48
|
+
case value
|
|
49
|
+
when Hash
|
|
50
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
51
|
+
result[key] =
|
|
52
|
+
if payload_key?(key) && leaf?(nested) && !nested.nil?
|
|
53
|
+
REDACTED
|
|
54
|
+
else
|
|
55
|
+
deep_redact(nested, include_payloads: include_payloads)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
when Array
|
|
59
|
+
value.map { |element| deep_redact(element, include_payloads: include_payloads) }
|
|
60
|
+
else
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A leaf is anything that isn't a structure we recurse into — i.e. the
|
|
66
|
+
# actual serialized payload value rather than an envelope hash/array.
|
|
67
|
+
def leaf?(value)
|
|
68
|
+
!value.is_a?(Hash) && !value.is_a?(Array)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/security_utils"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module MCP
|
|
7
|
+
# Boots the pgbus diagnostic MCP server over stdio. This is what
|
|
8
|
+
# `pgbus mcp` invokes. Separated from Server so the server assembly stays
|
|
9
|
+
# transport-agnostic and unit-testable without touching $stdin/$stdout.
|
|
10
|
+
#
|
|
11
|
+
# Security posture (issue #180):
|
|
12
|
+
# * Never starts unless explicitly invoked (`pgbus mcp`).
|
|
13
|
+
# * Reuses the app's existing DB connection/config — no new privileged
|
|
14
|
+
# path is opened.
|
|
15
|
+
# * Payloads are redacted unless PGBUS_MCP_ALLOW_PAYLOADS is truthy.
|
|
16
|
+
# * Optional token gate: when PGBUS_MCP_TOKEN is set, the same value must
|
|
17
|
+
# be present in PGBUS_MCP_AUTH_TOKEN at boot or the server refuses to
|
|
18
|
+
# start. stdio is a local, parent-spawned channel, so the gate is a
|
|
19
|
+
# boot-time precondition rather than a per-message header.
|
|
20
|
+
module Runner
|
|
21
|
+
TRUTHY = %w[1 true yes on].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Build the server, enforce the optional token gate, and drive the stdio
|
|
26
|
+
# transport until the client disconnects.
|
|
27
|
+
#
|
|
28
|
+
# @param env [Hash] environment to read flags from (injectable for tests).
|
|
29
|
+
def run(env: ENV)
|
|
30
|
+
authorize!(env)
|
|
31
|
+
|
|
32
|
+
allow_payloads = truthy?(env["PGBUS_MCP_ALLOW_PAYLOADS"])
|
|
33
|
+
server = Server.build(allow_payloads: allow_payloads)
|
|
34
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
35
|
+
log_start(allow_payloads)
|
|
36
|
+
transport.open
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raise unless the configured token matches. No-op when PGBUS_MCP_TOKEN
|
|
40
|
+
# is unset (token gating is opt-in).
|
|
41
|
+
def authorize!(env)
|
|
42
|
+
expected = env["PGBUS_MCP_TOKEN"]
|
|
43
|
+
return if expected.nil? || expected.empty?
|
|
44
|
+
|
|
45
|
+
provided = env["PGBUS_MCP_AUTH_TOKEN"]
|
|
46
|
+
return if secure_compare?(expected, provided)
|
|
47
|
+
|
|
48
|
+
raise Pgbus::Error,
|
|
49
|
+
"pgbus MCP: authentication failed. PGBUS_MCP_TOKEN is set; provide a matching PGBUS_MCP_AUTH_TOKEN."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def truthy?(value)
|
|
53
|
+
TRUTHY.include?(value.to_s.strip.downcase)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Constant-time comparison to avoid leaking the token via timing.
|
|
57
|
+
# Delegates to ActiveSupport's vetted implementation (railties already
|
|
58
|
+
# pulls in active_support). secure_compare handles unequal lengths
|
|
59
|
+
# safely — it compares fixed-length SHA256 digests, then verifies the
|
|
60
|
+
# raw values match — so it never raises on a length mismatch.
|
|
61
|
+
def secure_compare?(expected, provided)
|
|
62
|
+
return false if provided.nil?
|
|
63
|
+
|
|
64
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, provided)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def log_start(allow_payloads)
|
|
68
|
+
mode = allow_payloads ? "payloads ALLOWED" : "payloads redacted"
|
|
69
|
+
Pgbus.logger.info { "[Pgbus::MCP] starting read-only diagnostic server over stdio (#{mode})" }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
# Assembles the read-only pgbus diagnostic MCP server: registers every
|
|
6
|
+
# diagnostic tool and wires a DataSource (plus the payload gate) into the
|
|
7
|
+
# server context so tools never touch the database directly or leak
|
|
8
|
+
# payloads by default.
|
|
9
|
+
#
|
|
10
|
+
# This class only builds the +MCP::Server+ instance — transport is the
|
|
11
|
+
# caller's concern (Runner drives stdio). Keeping them separate means the
|
|
12
|
+
# same server can later be mounted over HTTP without changing tool code.
|
|
13
|
+
module Server
|
|
14
|
+
# The fixed, curated tool set. No arbitrary-SQL tool, no mutation tool —
|
|
15
|
+
# adding one here would violate the issue #180 security contract.
|
|
16
|
+
TOOLS = [
|
|
17
|
+
Tools::HealthTool,
|
|
18
|
+
Tools::QueuesTool,
|
|
19
|
+
Tools::QueueDetailTool,
|
|
20
|
+
Tools::ProcessesTool,
|
|
21
|
+
Tools::JobsTool,
|
|
22
|
+
Tools::JobDetailTool,
|
|
23
|
+
Tools::DlqTool,
|
|
24
|
+
Tools::DlqDetailTool,
|
|
25
|
+
Tools::LocksTool,
|
|
26
|
+
Tools::ThroughputTool,
|
|
27
|
+
Tools::StatsTool,
|
|
28
|
+
Tools::RecurringTool
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
INSTRUCTIONS = <<~TEXT
|
|
32
|
+
Read-only diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
|
|
33
|
+
Start with pgbus_health for a one-call OK/DEGRADED/STALLED verdict,
|
|
34
|
+
then drill in with pgbus_queues, pgbus_processes, pgbus_jobs,
|
|
35
|
+
pgbus_dlq, and pgbus_locks. No tool mutates state. Message payloads
|
|
36
|
+
are redacted unless the server was started with payloads explicitly
|
|
37
|
+
allowed.
|
|
38
|
+
TEXT
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Build an MCP::Server with all diagnostic tools registered.
|
|
43
|
+
#
|
|
44
|
+
# @param data_source [Pgbus::Web::DataSource] read layer the tools query.
|
|
45
|
+
# @param allow_payloads [Boolean] when true, tools honor a per-call
|
|
46
|
+
# include_payloads flag; otherwise payloads are always redacted.
|
|
47
|
+
def build(data_source: Pgbus::Web::DataSource.new, allow_payloads: false)
|
|
48
|
+
::MCP::Server.new(
|
|
49
|
+
name: "pgbus",
|
|
50
|
+
title: "Pgbus Diagnostics",
|
|
51
|
+
version: Pgbus::VERSION,
|
|
52
|
+
instructions: INSTRUCTIONS,
|
|
53
|
+
tools: TOOLS.dup,
|
|
54
|
+
server_context: {
|
|
55
|
+
data_source: data_source,
|
|
56
|
+
allow_payloads: allow_payloads
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Inspect a single dead-letter message. Maps to DataSource#job_detail
|
|
7
|
+
# against the named DLQ queue when +queue+ is supplied; falls back to a
|
|
8
|
+
# cross-DLQ scan via DataSource#dlq_message_detail when it is not.
|
|
9
|
+
# Payload redacted by default.
|
|
10
|
+
class DlqDetailTool < BaseTool
|
|
11
|
+
tool_name "pgbus_dlq_detail"
|
|
12
|
+
title "Pgbus Dead-Letter Detail"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
Inspect one dead-letter message: read_ct, vt, enqueued_at, source
|
|
15
|
+
queue. Pass the full physical DLQ queue name (e.g.
|
|
16
|
+
"pgbus_default_dlq") in +queue+ to disambiguate — msg_ids are only
|
|
17
|
+
unique within a single DLQ table, so the same id can exist in more
|
|
18
|
+
than one. Without +queue+ the tool scans every "_dlq" table and
|
|
19
|
+
returns the FIRST match, which is ambiguous when multiple DLQs
|
|
20
|
+
contain the id and is reported as an error. The message body and
|
|
21
|
+
headers are redacted unless the server allows payloads and
|
|
22
|
+
include_payloads is set.
|
|
23
|
+
DESC
|
|
24
|
+
|
|
25
|
+
input_schema(
|
|
26
|
+
properties: {
|
|
27
|
+
queue: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Full physical DLQ queue name (e.g. \"pgbus_default_dlq\"). Strongly recommended."
|
|
30
|
+
},
|
|
31
|
+
msg_id: { type: "integer", description: "Numeric PGMQ message id." },
|
|
32
|
+
include_payloads: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
description: "Include the raw message body/headers. Only honored when the server allows payloads."
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
required: %w[msg_id]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def self.call(msg_id:, queue: nil, include_payloads: false, server_context: nil)
|
|
41
|
+
data_source = data_source_from(server_context)
|
|
42
|
+
detail =
|
|
43
|
+
if queue
|
|
44
|
+
data_source.job_detail(queue, msg_id)
|
|
45
|
+
else
|
|
46
|
+
ambiguity_check(data_source, msg_id) || data_source.dlq_message_detail(msg_id)
|
|
47
|
+
end
|
|
48
|
+
return error_response("Dead-letter message #{msg_id} not found") unless detail
|
|
49
|
+
return detail if detail.is_a?(::MCP::Tool::Response)
|
|
50
|
+
|
|
51
|
+
json_response({ message: detail }, server_context: server_context, include_payloads: include_payloads)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# When the caller didn't specify a queue, scan every DLQ for the id and
|
|
55
|
+
# refuse to guess if more than one matches. Returns an error response
|
|
56
|
+
# on ambiguity, nil otherwise (so the caller proceeds with the normal
|
|
57
|
+
# first-match lookup).
|
|
58
|
+
def self.ambiguity_check(data_source, msg_id)
|
|
59
|
+
dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
|
|
60
|
+
dlqs = data_source.queues_with_metrics.select { |q| q[:name].to_s.end_with?(dlq_suffix) }
|
|
61
|
+
matches = dlqs.map { |q| q[:name] }.select { |name| data_source.job_detail(name, msg_id) }
|
|
62
|
+
return nil if matches.size <= 1
|
|
63
|
+
|
|
64
|
+
error_response(
|
|
65
|
+
"Dead-letter message #{msg_id} is ambiguous — present in #{matches.size} DLQs " \
|
|
66
|
+
"(#{matches.join(", ")}). Pass `queue:` to disambiguate."
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Paginated dead-letter queue inspection across all *_dlq queues.
|
|
7
|
+
# Maps to DataSource#dlq_messages. Payloads redacted by default.
|
|
8
|
+
class DlqTool < BaseTool
|
|
9
|
+
tool_name "pgbus_dlq"
|
|
10
|
+
title "Pgbus Dead-Letter Queue"
|
|
11
|
+
description <<~DESC
|
|
12
|
+
List messages sitting in dead-letter queues (queues whose name ends
|
|
13
|
+
in "_dlq") with read_ct, vt and enqueued_at. Paginated (default 25,
|
|
14
|
+
max 100). Message bodies and headers are redacted unless the server
|
|
15
|
+
allows payloads and include_payloads is set.
|
|
16
|
+
DESC
|
|
17
|
+
|
|
18
|
+
MAX_PER_PAGE = 100
|
|
19
|
+
MAX_PAGE = 1_000
|
|
20
|
+
|
|
21
|
+
input_schema(
|
|
22
|
+
properties: {
|
|
23
|
+
page: { type: "integer", description: "1-based page number (default 1).", minimum: 1 },
|
|
24
|
+
per_page: { type: "integer", description: "Rows per page (default 25, max 100).", minimum: 1 },
|
|
25
|
+
include_payloads: {
|
|
26
|
+
type: "boolean",
|
|
27
|
+
description: "Include raw message bodies/headers. Only honored when the server allows payloads."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: []
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def self.call(page: 1, per_page: 25, include_payloads: false, server_context: nil)
|
|
34
|
+
data_source = data_source_from(server_context)
|
|
35
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
36
|
+
page = page.to_i.clamp(1, MAX_PAGE)
|
|
37
|
+
rows = data_source.dlq_messages(page: page, per_page: per_page)
|
|
38
|
+
|
|
39
|
+
json_response(
|
|
40
|
+
{ page: page, per_page: per_page, messages: rows },
|
|
41
|
+
server_context: server_context,
|
|
42
|
+
include_payloads: include_payloads
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Top-level health verdict: OK / DEGRADED / STALLED, with the reasons
|
|
7
|
+
# that drove it. Delegates the actual analysis to HealthAnalyzer, which
|
|
8
|
+
# reads the same DataSource surface the dashboard uses. This is the
|
|
9
|
+
# single tool that catches the silent-worker-wedge class of incident.
|
|
10
|
+
class HealthTool < BaseTool
|
|
11
|
+
tool_name "pgbus_health"
|
|
12
|
+
title "Pgbus Health"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
Top-level pgbus health verdict. Returns OK, DEGRADED, or STALLED with
|
|
15
|
+
the specific reasons. STALLED means the silent-wedge condition:
|
|
16
|
+
queues have visible backlog while workers are heart-beating but not
|
|
17
|
+
claiming (or a worker's claim loop has stalled). DEGRADED covers
|
|
18
|
+
stale processes, paused queues holding a backlog, dead-letter
|
|
19
|
+
buildup, or a long-running transaction pinning the MVCC horizon. Use
|
|
20
|
+
this first when triaging — it summarizes the whole system in one call
|
|
21
|
+
and is suitable for automated alerting.
|
|
22
|
+
DESC
|
|
23
|
+
|
|
24
|
+
input_schema(properties: {}, required: [])
|
|
25
|
+
|
|
26
|
+
def self.call(server_context: nil)
|
|
27
|
+
data_source = data_source_from(server_context)
|
|
28
|
+
json_response(HealthAnalyzer.new(data_source).verdict)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Inspect a single enqueued message by queue + msg_id. Returns its
|
|
7
|
+
# metadata (read_ct, vt, enqueued_at, last_read_at); the body and headers
|
|
8
|
+
# are redacted unless payloads are explicitly allowed. Maps to
|
|
9
|
+
# DataSource#job_detail.
|
|
10
|
+
class JobDetailTool < BaseTool
|
|
11
|
+
tool_name "pgbus_job_detail"
|
|
12
|
+
title "Pgbus Job Detail"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
Inspect one enqueued message: read_ct, visibility timeout (vt),
|
|
15
|
+
enqueued_at, last_read_at. Pass the full physical queue name and the
|
|
16
|
+
numeric msg_id. The message body and headers are redacted unless the
|
|
17
|
+
server allows payloads and include_payloads is set.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
queue: { type: "string", description: "Full physical queue name (e.g. \"pgbus_default\")." },
|
|
23
|
+
msg_id: { type: "integer", description: "Numeric PGMQ message id." },
|
|
24
|
+
include_payloads: {
|
|
25
|
+
type: "boolean",
|
|
26
|
+
description: "Include the raw message body/headers. Only honored when the server allows payloads."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
required: %w[queue msg_id]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def self.call(queue:, msg_id:, include_payloads: false, server_context: nil)
|
|
33
|
+
data_source = data_source_from(server_context)
|
|
34
|
+
detail = data_source.job_detail(queue, msg_id)
|
|
35
|
+
return error_response("Message #{msg_id} not found in #{queue}") unless detail
|
|
36
|
+
|
|
37
|
+
json_response({ job: detail }, server_context: server_context, include_payloads: include_payloads)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|