sidekiq-mcp 0.1.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.
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "base64"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Sidekiq
9
+ module Mcp
10
+ class SseMiddleware
11
+ SSE_HEADERS = {
12
+ 'Content-Type' => 'text/event-stream',
13
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
14
+ 'Connection' => 'keep-alive',
15
+ 'X-Accel-Buffering' => 'no', # For Nginx
16
+ 'Access-Control-Allow-Origin' => '*',
17
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
18
+ 'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
19
+ 'Access-Control-Max-Age' => '86400',
20
+ 'Keep-Alive' => 'timeout=600',
21
+ 'Pragma' => 'no-cache',
22
+ 'Expires' => '0'
23
+ }.freeze
24
+
25
+ def initialize(app)
26
+ @app = app
27
+ @server = Server.new
28
+ @clients = {}
29
+ @clients_mutex = Mutex.new
30
+ end
31
+
32
+ def call(env)
33
+ request = Rack::Request.new(env)
34
+
35
+ return @app.call(env) unless sse_request?(request)
36
+ return unauthorized_response unless authorized?(request)
37
+
38
+ if request.get?
39
+ handle_sse_connection(request, env)
40
+ elsif request.post?
41
+ handle_mcp_message(request)
42
+ else
43
+ method_not_allowed_response
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def sse_request?(request)
50
+ config = Sidekiq::Mcp.configuration || Sidekiq::Mcp::Configuration.new
51
+ request.path == "#{config.path}/sse"
52
+ end
53
+
54
+ def authorized?(request)
55
+ config = Sidekiq::Mcp.configuration || Sidekiq::Mcp::Configuration.new
56
+
57
+ return true unless config.auth_token
58
+
59
+ auth_header = request.env["HTTP_AUTHORIZATION"]
60
+ return false unless auth_header
61
+
62
+ if auth_header.start_with?("Bearer ")
63
+ token = auth_header.split(" ", 2).last
64
+ token == config.auth_token
65
+ elsif auth_header.start_with?("Basic ")
66
+ encoded = auth_header.split(" ", 2).last
67
+ decoded = Base64.decode64(encoded)
68
+ _username, password = decoded.split(":", 2)
69
+ password == config.auth_token
70
+ else
71
+ false
72
+ end
73
+ end
74
+
75
+ def handle_sse_connection(request, env)
76
+ client_id = SecureRandom.hex(8)
77
+
78
+ [200, SSE_HEADERS, SseStreamer.new(client_id, self)]
79
+ end
80
+
81
+ def handle_mcp_message(request)
82
+ body = request.body.read
83
+ response = @server.handle_request(body)
84
+
85
+ # Broadcast to all SSE clients
86
+ broadcast_to_clients(response) if response
87
+
88
+ if response.nil?
89
+ [204, {}, []]
90
+ else
91
+ [200, {"Content-Type" => "application/json"}, [response.to_json]]
92
+ end
93
+ end
94
+
95
+ def broadcast_to_clients(message)
96
+ @clients_mutex.synchronize do
97
+ @clients.each_value do |client_data|
98
+ begin
99
+ client_data[:stream].write("data: #{message.to_json}\n\n")
100
+ client_data[:stream].flush
101
+ rescue
102
+ # Client disconnected, will be cleaned up later
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def add_client(client_id, stream)
109
+ @clients_mutex.synchronize do
110
+ @clients[client_id] = { stream: stream, connected_at: Time.now }
111
+ end
112
+ end
113
+
114
+ def remove_client(client_id)
115
+ @clients_mutex.synchronize do
116
+ @clients.delete(client_id)
117
+ end
118
+ end
119
+
120
+ def unauthorized_response
121
+ [401, { "Content-Type" => "application/json" }, [{ error: "Unauthorized" }.to_json]]
122
+ end
123
+
124
+ def method_not_allowed_response
125
+ [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
126
+ end
127
+
128
+ class SseStreamer
129
+ def initialize(client_id, middleware)
130
+ @client_id = client_id
131
+ @middleware = middleware
132
+ end
133
+
134
+ def each
135
+ @middleware.add_client(@client_id, self)
136
+
137
+ # Send initial connection message
138
+ yield "data: #{initial_message.to_json}\n\n"
139
+
140
+ # Keep connection alive with heartbeats
141
+ begin
142
+ loop do
143
+ sleep 30
144
+ yield ": heartbeat\n\n"
145
+ end
146
+ rescue
147
+ # Connection closed
148
+ ensure
149
+ @middleware.remove_client(@client_id)
150
+ end
151
+ end
152
+
153
+ def write(data)
154
+ @data = data
155
+ end
156
+
157
+ def flush
158
+ # This method is called by broadcast_to_clients
159
+ # In a real streaming setup, we'd need to queue messages
160
+ # For now, this is a simplified implementation
161
+ end
162
+
163
+ private
164
+
165
+ def initial_message
166
+ {
167
+ jsonrpc: "2.0",
168
+ method: "connection/established",
169
+ params: {
170
+ client_id: @client_id,
171
+ server_info: {
172
+ name: "sidekiq-mcp",
173
+ version: Sidekiq::Mcp::VERSION
174
+ }
175
+ }
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-schema"
4
+
5
+ module Sidekiq
6
+ module Mcp
7
+ class Tool
8
+ class << self
9
+ attr_accessor :tool_description, :argument_schema
10
+
11
+ def description(text)
12
+ @tool_description = text
13
+ end
14
+
15
+ def arguments(&block)
16
+ @argument_schema = Dry::Schema.Params(&block) if block_given?
17
+ end
18
+
19
+ def schema_to_json_schema
20
+ return { type: "object", properties: {}, required: [] } unless @argument_schema
21
+
22
+ # For now, return a simple schema - can be enhanced later
23
+ {
24
+ type: "object",
25
+ properties: {},
26
+ required: []
27
+ }
28
+ end
29
+ end
30
+
31
+ def self.to_tool_definition
32
+ tool_name = name.split("::").last.gsub(/Tool$/, "").gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
33
+ {
34
+ name: tool_name,
35
+ description: @tool_description || "#{name} tool",
36
+ inputSchema: schema_to_json_schema
37
+ }
38
+ end
39
+
40
+ def call(**args)
41
+ # Validate arguments if schema is defined
42
+ if self.class.argument_schema
43
+ result = self.class.argument_schema.call(args)
44
+ raise ArgumentError, "Invalid arguments: #{result.errors.to_h}" unless result.success?
45
+ args = result.to_h
46
+ end
47
+
48
+ perform(**args)
49
+ end
50
+
51
+ def perform(**args)
52
+ raise NotImplementedError, "Subclasses must implement #perform"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class ClearQueueTool < Tool
10
+ description "Clear all jobs from a specific queue (destructive operation)"
11
+
12
+ arguments do
13
+ required(:queue_name).filled(:string)
14
+ end
15
+
16
+ def perform(queue_name:)
17
+ queue = Sidekiq::Queue.new(queue_name)
18
+ initial_size = queue.size
19
+
20
+ if initial_size == 0
21
+ "Queue '#{queue_name}' is already empty"
22
+ else
23
+ queue.clear
24
+ "Cleared #{initial_size} jobs from queue '#{queue_name}'"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class DeadJobsTool < Tool
10
+ description "Show jobs in the dead set (jobs that have exhausted all retries)"
11
+
12
+ arguments do
13
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100)
14
+ end
15
+
16
+ def perform(limit: 10)
17
+ jobs = Sidekiq::DeadSet.new.first(limit).map do |job|
18
+ {
19
+ jid: job.jid,
20
+ class: job.klass,
21
+ args: job.args,
22
+ queue: job.queue,
23
+ error_message: job["error_message"],
24
+ error_class: job["error_class"],
25
+ failed_at: job["failed_at"],
26
+ retry_count: job["retry_count"],
27
+ died_at: job["died_at"]
28
+ }
29
+ end
30
+
31
+ {
32
+ total_size: Sidekiq::DeadSet.new.size,
33
+ jobs: jobs
34
+ }.to_json
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class FailedJobsTool < Tool
10
+ description "List failed jobs with error details and retry information"
11
+
12
+ arguments do
13
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100)
14
+ end
15
+
16
+ def perform(limit: 10)
17
+ failed_set = Sidekiq::RetrySet.new
18
+ jobs = failed_set.first(limit).map do |job|
19
+ {
20
+ jid: job.jid,
21
+ class: job.klass,
22
+ args: job.args,
23
+ queue: job.queue,
24
+ error_message: job["error_message"],
25
+ error_class: job["error_class"],
26
+ failed_at: job["failed_at"],
27
+ retry_count: job["retry_count"],
28
+ retried_at: job["retried_at"]
29
+ }
30
+ end
31
+
32
+ {
33
+ total_size: failed_set.size,
34
+ jobs: jobs
35
+ }.to_json
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class JobClassStatsTool < Tool
10
+ description "Breakdown of job counts, retries, and error rates by class"
11
+
12
+ arguments do
13
+ # No arguments needed
14
+ end
15
+
16
+ def perform
17
+ stats = {}
18
+
19
+ # Get stats from retry set
20
+ Sidekiq::RetrySet.new.each do |job|
21
+ klass = job.klass
22
+ stats[klass] ||= { retry_count: 0, failed_count: 0, total_retries: 0 }
23
+ stats[klass][:retry_count] += 1
24
+ stats[klass][:total_retries] += (job["retry_count"] || 0)
25
+ end
26
+
27
+ # Get stats from dead set
28
+ Sidekiq::DeadSet.new.each do |job|
29
+ klass = job.klass
30
+ stats[klass] ||= { retry_count: 0, failed_count: 0, total_retries: 0 }
31
+ stats[klass][:failed_count] += 1
32
+ end
33
+
34
+ # Get stats from queues
35
+ Sidekiq::Queue.all.each do |queue|
36
+ queue.each do |job|
37
+ klass = job.klass
38
+ stats[klass] ||= { retry_count: 0, failed_count: 0, total_retries: 0, enqueued_count: 0 }
39
+ stats[klass][:enqueued_count] = (stats[klass][:enqueued_count] || 0) + 1
40
+ end
41
+ end
42
+
43
+ # Calculate error rates
44
+ stats.each do |klass, data|
45
+ total_jobs = (data[:retry_count] || 0) + (data[:failed_count] || 0) + (data[:enqueued_count] || 0)
46
+ error_count = (data[:retry_count] || 0) + (data[:failed_count] || 0)
47
+ data[:error_rate] = total_jobs > 0 ? (error_count.to_f / total_jobs * 100).round(2) : 0
48
+ data[:total_jobs] = total_jobs
49
+ end
50
+
51
+ stats.to_json
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class JobDetailsTool < Tool
10
+ description "Show args, error message, and history for a job by JID (failed, scheduled, or retry)"
11
+
12
+ arguments do
13
+ required(:jid).filled(:string)
14
+ end
15
+
16
+ def perform(jid:)
17
+ job = find_job_by_jid(jid)
18
+
19
+ return "Job #{jid} not found" unless job
20
+
21
+ details = {
22
+ jid: job.jid,
23
+ class: job.klass,
24
+ args: job.args,
25
+ queue: job.queue,
26
+ created_at: job.created_at,
27
+ enqueued_at: job.enqueued_at
28
+ }
29
+
30
+ # Add retry/failure specific info
31
+ if job.respond_to?(:[])
32
+ details[:error_message] = job["error_message"] if job["error_message"]
33
+ details[:error_class] = job["error_class"] if job["error_class"]
34
+ details[:failed_at] = job["failed_at"] if job["failed_at"]
35
+ details[:retry_count] = job["retry_count"] if job["retry_count"]
36
+ details[:retried_at] = job["retried_at"] if job["retried_at"]
37
+ details[:backtrace] = job["error_backtrace"] if job["error_backtrace"]
38
+ end
39
+
40
+ # Add scheduled info
41
+ if job.respond_to?(:at)
42
+ details[:scheduled_at] = job.at
43
+ end
44
+
45
+ details.to_json
46
+ end
47
+
48
+ private
49
+
50
+ def find_job_by_jid(jid)
51
+ # Check retry set
52
+ job = Sidekiq::RetrySet.new.find_job(jid)
53
+ return job if job
54
+
55
+ # Check scheduled set
56
+ job = Sidekiq::ScheduledSet.new.find_job(jid)
57
+ return job if job
58
+
59
+ # Check dead set
60
+ job = Sidekiq::DeadSet.new.find_job(jid)
61
+ return job if job
62
+
63
+ # Check all queues
64
+ Sidekiq::Queue.all.each do |queue|
65
+ job = queue.find_job(jid)
66
+ return job if job
67
+ end
68
+
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class KillJobTool < Tool
10
+ description "Move a job from retry/scheduled set to the dead set"
11
+
12
+ arguments do
13
+ required(:jid).filled(:string)
14
+ end
15
+
16
+ def perform(jid:)
17
+ # Try retry set first
18
+ retry_set = Sidekiq::RetrySet.new
19
+ job = retry_set.find_job(jid)
20
+
21
+ if job
22
+ job.kill
23
+ return "Job #{jid} moved from retry set to dead set"
24
+ end
25
+
26
+ # Try scheduled set
27
+ scheduled_set = Sidekiq::ScheduledSet.new
28
+ job = scheduled_set.find_job(jid)
29
+
30
+ if job
31
+ job.kill
32
+ return "Job #{jid} moved from scheduled set to dead set"
33
+ end
34
+
35
+ "Job #{jid} not found in retry or scheduled sets"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class ListRetryJobsTool < Tool
10
+ description "List jobs in the retry set (jobs that failed but will be retried)"
11
+
12
+ arguments do
13
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100)
14
+ end
15
+
16
+ def perform(limit: 10)
17
+ jobs = Sidekiq::RetrySet.new.first(limit).map do |job|
18
+ {
19
+ jid: job.jid,
20
+ class: job.klass,
21
+ args: job.args,
22
+ queue: job.queue,
23
+ error_message: job["error_message"],
24
+ error_class: job["error_class"],
25
+ failed_at: job["failed_at"],
26
+ retry_count: job["retry_count"],
27
+ retried_at: job["retried_at"],
28
+ next_retry_at: job.at
29
+ }
30
+ end
31
+
32
+ {
33
+ total_size: Sidekiq::RetrySet.new.size,
34
+ jobs: jobs
35
+ }.to_json
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class ListScheduledJobsTool < Tool
10
+ description "List jobs in the scheduled set"
11
+
12
+ arguments do
13
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100)
14
+ end
15
+
16
+ def perform(limit: 10)
17
+ jobs = Sidekiq::ScheduledSet.new.first(limit).map do |job|
18
+ {
19
+ jid: job.jid,
20
+ class: job.klass,
21
+ args: job.args,
22
+ queue: job.queue,
23
+ scheduled_at: job.at,
24
+ created_at: job.created_at
25
+ }
26
+ end
27
+
28
+ {
29
+ total_size: Sidekiq::ScheduledSet.new.size,
30
+ jobs: jobs
31
+ }.to_json
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class ProcessSetTool < Tool
10
+ description "Get detailed information about all Sidekiq processes/workers"
11
+
12
+ arguments do
13
+ # No arguments needed
14
+ end
15
+
16
+ def perform
17
+ processes = Sidekiq::ProcessSet.new.map do |process|
18
+ {
19
+ identity: process["identity"],
20
+ hostname: process["hostname"],
21
+ pid: process["pid"],
22
+ tag: process["tag"],
23
+ concurrency: process["concurrency"],
24
+ queues: process["queues"],
25
+ busy: process["busy"],
26
+ beat: process["beat"],
27
+ quiet: process["quiet"],
28
+ started_at: process["started_at"],
29
+ labels: process["labels"],
30
+ version: process["version"],
31
+ rss_kb: process["rss"]
32
+ }
33
+ end
34
+
35
+ {
36
+ total_processes: processes.size,
37
+ processes: processes
38
+ }.to_json
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool"
4
+ require "sidekiq/api"
5
+
6
+ module Sidekiq
7
+ module Mcp
8
+ module Tools
9
+ class QueueDetailsTool < Tool
10
+ description "Get detailed information about a specific queue including jobs"
11
+
12
+ arguments do
13
+ required(:queue_name).filled(:string)
14
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100)
15
+ end
16
+
17
+ def perform(queue_name:, limit: 10)
18
+ queue = Sidekiq::Queue.new(queue_name)
19
+ jobs = queue.first(limit).map do |job|
20
+ {
21
+ jid: job.jid,
22
+ class: job.klass,
23
+ args: job.args,
24
+ created_at: job.created_at,
25
+ enqueued_at: job.enqueued_at,
26
+ queue: job.queue
27
+ }
28
+ end
29
+
30
+ {
31
+ queue_name: queue_name,
32
+ size: queue.size,
33
+ latency: queue.latency,
34
+ jobs: jobs
35
+ }.to_json
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end