sillydev 1.0.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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SillyDev
4
+ # Removing the panel's {"object", "attributes"} envelopes.
5
+ module Envelope
6
+ ITEM_KEYS = %w[object attributes meta].freeze
7
+ LIST_KEYS = %w[object data meta].freeze
8
+
9
+ module_function
10
+
11
+ # Strips the envelopes from an API response, at every level.
12
+ #
13
+ # {"object" => "server", "attributes" => {...}} becomes the attributes,
14
+ # {"object" => "list", "data" => [...]} becomes the list of items, a map such as
15
+ # {"object" => "server_stats_list", "data" => {uuid => item}} becomes {uuid => attributes},
16
+ # and a null_resource (a relationship the key may not see) becomes nil.
17
+ def unwrap(value)
18
+ case value
19
+ when Array
20
+ value.map { |item| unwrap(item) }
21
+ when Hash
22
+ kind = value["object"]
23
+ if kind.is_a?(String)
24
+ return nil if kind == "null_resource"
25
+ return unwrap(value["attributes"]) if value.key?("attributes") && (value.keys - ITEM_KEYS).empty?
26
+ if value.key?("data") && (value.keys - LIST_KEYS).empty? && (value["data"].is_a?(Array) || value["data"].is_a?(Hash))
27
+ return unwrap(value["data"])
28
+ end
29
+ end
30
+ value.to_h { |key, item| [key, unwrap(item)] }
31
+ else
32
+ value
33
+ end
34
+ end
35
+ end
36
+
37
+ # One page of a paginated list: the unwrapped items plus the list's meta.
38
+ class Page
39
+ include Enumerable
40
+
41
+ attr_reader :data, :meta
42
+
43
+ def self.from_response(body)
44
+ return new([], {}) unless body.is_a?(Hash)
45
+
46
+ meta = body["meta"].is_a?(Hash) ? Envelope.unwrap(body["meta"]) : {}
47
+ new(Array(Envelope.unwrap(body["data"] || [])), meta)
48
+ end
49
+
50
+ def initialize(data, meta = {})
51
+ @data = data
52
+ @meta = meta
53
+ end
54
+
55
+ def pagination
56
+ meta["pagination"].is_a?(Hash) ? meta["pagination"] : {}
57
+ end
58
+
59
+ def current_page
60
+ (pagination["current_page"] || 1).to_i
61
+ end
62
+
63
+ def total_pages
64
+ (pagination["total_pages"] || 1).to_i
65
+ end
66
+
67
+ def total
68
+ pagination["total"]&.to_i
69
+ end
70
+
71
+ def next_page?
72
+ !data.empty? && current_page < total_pages
73
+ end
74
+
75
+ def each(&)
76
+ data.each(&)
77
+ end
78
+
79
+ def size
80
+ data.size
81
+ end
82
+ alias length size
83
+ end
84
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SillyDev
6
+ # Base class for every error this gem raises.
7
+ #
8
+ # Errors the API answers with come as {"errors": [{"code", "status", "detail"}]}: the message is
9
+ # the first detail and #errors holds the whole list.
10
+ class Error < StandardError
11
+ attr_reader :status_code, :errors, :response
12
+
13
+ def initialize(message = nil, status_code: nil, errors: nil, response: nil)
14
+ super(message)
15
+ @status_code = status_code
16
+ @errors = errors || []
17
+ @response = response
18
+ end
19
+
20
+ # The code of the first error, such as "NotFoundHttpException".
21
+ def code
22
+ first = @errors.first
23
+ first && !first["code"].nil? ? first["code"].to_s : nil
24
+ end
25
+ end
26
+
27
+ # The API could not be reached (DNS, TLS, connection refused or a timeout).
28
+ class APIConnectionError < Error; end
29
+
30
+ # The API answered with an error status that has no more specific class.
31
+ class APIStatusError < Error; end
32
+
33
+ # 400: the request could not be carried out as sent.
34
+ class BadRequestError < APIStatusError; end
35
+
36
+ # 401: no API key, or it is wrong, expired or deleted.
37
+ class AuthenticationError < APIStatusError; end
38
+
39
+ # 403: the key is valid but may not do this.
40
+ class PermissionDeniedError < APIStatusError; end
41
+
42
+ # 404: no such resource, the key cannot see it, or the feature is switched off.
43
+ class NotFoundError < APIStatusError; end
44
+
45
+ # 409: the resource is busy or in a state that does not allow this right now.
46
+ class ConflictError < APIStatusError; end
47
+
48
+ # 422: some fields were missing or invalid. #errors has one entry per problem.
49
+ class ValidationError < APIStatusError
50
+ # The details grouped by the field they are about (meta.source_field).
51
+ def fields
52
+ errors.each_with_object({}) do |error, out|
53
+ meta = error["meta"].is_a?(Hash) ? error["meta"] : {}
54
+ (out[meta["source_field"].to_s] ||= []) << error["detail"].to_s
55
+ end
56
+ end
57
+ end
58
+
59
+ # 429: too many requests. #retry_after is the number of seconds to wait, if sent.
60
+ class RateLimitError < APIStatusError
61
+ attr_reader :retry_after
62
+
63
+ def initialize(message = nil, retry_after: nil, **kwargs)
64
+ super(message, **kwargs)
65
+ @retry_after = retry_after
66
+ end
67
+ end
68
+
69
+ # 5xx: something went wrong on the panel or on the node running the server.
70
+ class ServerError < APIStatusError; end
71
+
72
+ # A wait such as servers.wait_for_state ran out of time. #state is the server's state when it
73
+ # gave up.
74
+ class WaitTimeoutError < Error
75
+ attr_reader :state
76
+
77
+ def initialize(message = nil, state: nil)
78
+ super(message)
79
+ @state = state
80
+ end
81
+ end
82
+
83
+ # A console connection could not be opened, or it ended.
84
+ #
85
+ # #reason says why: "suspended" (the server is suspended; connecting again won't help),
86
+ # "booting" (the node is still loading the server; try again in a few seconds), "token" (the
87
+ # node refused the token), "timeout", "network" (the node or the panel could not be reached, or
88
+ # the connection dropped), "deleted" (the server has been deleted), "throttled" (messages were
89
+ # sent faster than the node takes them) or "unavailable" (this way of connecting isn't
90
+ # available here). #transport is the way that failed, and with transport "auto", #attempts holds
91
+ # why each way tried failed.
92
+ class ConsoleError < Error
93
+ attr_reader :reason, :transport, :attempts
94
+
95
+ def initialize(message = nil, reason = "network", transport: nil, attempts: nil)
96
+ super(message)
97
+ @reason = reason
98
+ @transport = transport
99
+ @attempts = attempts || []
100
+ end
101
+ end
102
+
103
+ # Turning error responses into exceptions.
104
+ module Errors
105
+ BY_STATUS = {
106
+ 400 => BadRequestError,
107
+ 401 => AuthenticationError,
108
+ 403 => PermissionDeniedError,
109
+ 404 => NotFoundError,
110
+ 409 => ConflictError,
111
+ 422 => ValidationError,
112
+ }.freeze
113
+
114
+ module_function
115
+
116
+ # Seconds from a Retry-After header, or nil when it is missing or not a number.
117
+ def parse_retry_after(value)
118
+ return nil if value.nil?
119
+
120
+ seconds = Float(value.to_s.strip)
121
+ seconds.negative? ? 0.0 : seconds
122
+ rescue ArgumentError
123
+ nil
124
+ end
125
+
126
+ # The exception that matches an error response.
127
+ def for_response(response)
128
+ status = response.status
129
+ body = begin
130
+ JSON.parse(response.body.to_s)
131
+ rescue JSON::ParserError
132
+ nil
133
+ end
134
+ errors = []
135
+ if body.is_a?(Hash)
136
+ if body["errors"].is_a?(Array)
137
+ errors = body["errors"].grep(Hash)
138
+ elsif body["error"].is_a?(String)
139
+ # The node (Wings) answers signed-URL requests in its own format.
140
+ error = { "code" => "NodeError", "status" => status.to_s, "detail" => body["error"] }
141
+ # The console relay passes the node's refusals on in the same format.
142
+ meta = body.slice("booting", "suspended", "jwt").select { |_, v| [true, false].include?(v) }
143
+ error["meta"] = meta unless meta.empty?
144
+ errors = [error]
145
+ elsif body["message"].is_a?(String)
146
+ errors = [{ "code" => "HttpException", "status" => status.to_s, "detail" => body["message"] }]
147
+ end
148
+ end
149
+ message = errors.empty? ? "" : (errors.first["detail"] || errors.first["code"]).to_s
150
+ message = "HTTP error" if message.empty?
151
+ message = "#{status}: #{message}"
152
+
153
+ kwargs = { status_code: status, errors: errors, response: response }
154
+ return RateLimitError.new(message, retry_after: parse_retry_after(response.header("retry-after")), **kwargs) if status == 429
155
+
156
+ klass = BY_STATUS[status] || (status >= 500 ? ServerError : APIStatusError)
157
+ klass.new(message, **kwargs)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../sillydev"
5
+
6
+ module SillyDev
7
+ # An MCP server that lets AI assistants look after your Silly Development servers.
8
+ #
9
+ # Run it with `sillydev mcp`. It speaks MCP (JSON-RPC 2.0, one message per line) over stdio and
10
+ # reads the API key from SILLYDEV_API_KEY. Give it a key limited to the servers and permissions
11
+ # you want the assistant to have.
12
+ #
13
+ # The tools are deliberately few: list servers, read a server's status, power actions, console
14
+ # commands, reading recent console output, list and read files, Git deploys and listing backups.
15
+ # There is nothing that spends money, deletes anything or changes who can reach a server.
16
+ class MCPServer
17
+ PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
18
+
19
+ # read_file returns at most this many characters, so one large log cannot swamp the context.
20
+ MAX_FILE_CHARS = 100_000
21
+
22
+ INSTRUCTIONS = "Tools for the user's Silly Development servers (Discord bots and other apps). " \
23
+ "Servers are named by their 8-character identifier, which list_servers returns. " \
24
+ "Power actions and console commands affect a live server: say what you are about to do first."
25
+
26
+ SERVER = { "server" => { "type" => "string", "description" => "The server's 8-character identifier or UUID." } }.freeze
27
+
28
+ def self.schema(properties = {}, required = [])
29
+ { "type" => "object", "properties" => properties, "required" => required }
30
+ end
31
+
32
+ TOOLS = [
33
+ ["list_servers", "List the servers this API key can reach, with their identifiers, names and limits.", schema],
34
+ ["server_status",
35
+ "Get a server's live state (offline, starting, running, stopping) and its CPU, memory, disk and network use.",
36
+ schema(SERVER, ["server"])],
37
+ ["power_action", "Start, stop, restart or kill a server. signal is one of start, stop, restart, kill.",
38
+ schema(SERVER.merge("signal" => { "type" => "string", "enum" => %w[start stop restart kill] }), %w[server signal])],
39
+ ["send_command", "Send a command to a server's console, as if typed into it.",
40
+ schema(SERVER.merge("command" => { "type" => "string" }), %w[server command])],
41
+ ["read_console",
42
+ "Read a server's recent console output (about the last 150 lines), oldest first. lines limits it to the " \
43
+ "latest lines. Empty while the server is offline.",
44
+ schema(SERVER.merge("lines" => { "type" => "integer", "default" => 100 }), ["server"])],
45
+ ["list_files", "List the files and folders in one directory of a server. directory defaults to /.",
46
+ schema(SERVER.merge("directory" => { "type" => "string", "default" => "/" }), ["server"])],
47
+ ["read_file", "Read a text file from a server, such as a config file or a log.",
48
+ schema(SERVER.merge("path" => { "type" => "string" }), %w[server path])],
49
+ ["git_deploy", "Deploy the Git repository linked to a server now. Returns the queued deployment.",
50
+ schema(SERVER, ["server"])],
51
+ ["list_backups", "List a server's backups: name, size, whether it finished and succeeded, and when.",
52
+ schema(SERVER, ["server"])],
53
+ ].map { |name, description, input| { "name" => name, "description" => description, "inputSchema" => input } }.freeze
54
+
55
+ TOOL_NAMES = TOOLS.map { |tool| tool["name"] }.freeze
56
+
57
+ def self.main(api_key: nil, base_url: nil, stdin: $stdin, stdout: $stdout, stderr: $stderr)
58
+ client = Client.new(api_key, base_url: base_url)
59
+ new(client).serve(stdin, stdout)
60
+ 0
61
+ rescue Error => e
62
+ stderr.puts("sillydev mcp: #{e.message}")
63
+ 1
64
+ end
65
+
66
+ def initialize(client)
67
+ @client = client
68
+ end
69
+
70
+ # Answers messages from input, one JSON-RPC message per line, until it ends.
71
+ def serve(input, output)
72
+ output.sync = true if output.respond_to?(:sync=)
73
+ input.each_line do |line|
74
+ next if line.strip.empty?
75
+
76
+ reply = handle_line(line)
77
+ output.puts(JSON.generate(reply)) if reply
78
+ end
79
+ end
80
+
81
+ # The answer to one line (a message or a batch), or nil for notifications.
82
+ def handle_line(line)
83
+ message = JSON.parse(line)
84
+ if message.is_a?(Array)
85
+ replies = message.filter_map { |m| handle(m) }
86
+ return replies.empty? ? nil : replies
87
+ end
88
+ handle(message)
89
+ rescue JSON::ParserError
90
+ error_reply(nil, -32_700, "Parse error")
91
+ end
92
+
93
+ def handle(message)
94
+ return error_reply(nil, -32_600, "Invalid Request") unless message.is_a?(Hash)
95
+
96
+ id = message["id"]
97
+ method = message["method"]
98
+ return nil if method.nil? || (!message.key?("id") && method.start_with?("notifications/"))
99
+ return nil unless message.key?("id")
100
+
101
+ result = case method
102
+ when "initialize" then initialize_result(message["params"] || {})
103
+ when "ping" then {}
104
+ when "tools/list" then { "tools" => TOOLS }
105
+ when "tools/call" then call_tool(message["params"] || {})
106
+ else return error_reply(id, -32_601, "Method not found: #{method}")
107
+ end
108
+ { "jsonrpc" => "2.0", "id" => id, "result" => result }
109
+ rescue ArgumentError => e
110
+ error_reply(id, -32_602, e.message)
111
+ end
112
+
113
+ # Runs one tool. Errors from the API come back as a tool result with isError, so the
114
+ # assistant can read them.
115
+ def call_tool(params)
116
+ name = params["name"]
117
+ args = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
118
+ raise ArgumentError, "Unknown tool: #{name}" unless TOOL_NAMES.include?(name)
119
+
120
+ value = run_tool(name, args)
121
+ text = value.is_a?(String) ? value : JSON.pretty_generate(value)
122
+ { "content" => [{ "type" => "text", "text" => text }], "isError" => false }
123
+ rescue Error, ToolError => e
124
+ { "content" => [{ "type" => "text", "text" => e.message }], "isError" => true }
125
+ end
126
+
127
+ private
128
+
129
+ class ToolError < StandardError; end
130
+
131
+ def initialize_result(params)
132
+ asked = params["protocolVersion"]
133
+ {
134
+ "protocolVersion" => PROTOCOL_VERSIONS.include?(asked) ? asked : PROTOCOL_VERSIONS.first,
135
+ "capabilities" => { "tools" => { "listChanged" => false } },
136
+ "serverInfo" => { "name" => "sillydev", "version" => VERSION },
137
+ "instructions" => INSTRUCTIONS,
138
+ }
139
+ end
140
+
141
+ def arg(args, name)
142
+ value = args[name]
143
+ raise ToolError, "#{name} is required" if value.nil? || value.to_s.empty?
144
+
145
+ value.to_s
146
+ end
147
+
148
+ def run_tool(name, args)
149
+ panel = @client.panel
150
+ case name
151
+ when "list_servers"
152
+ keys = %w[identifier uuid name description node limits is_suspended is_installing is_paid]
153
+ panel.servers.list.map { |s| s.slice(*keys) }
154
+ when "server_status" then panel.servers.resources(arg(args, "server"))
155
+ when "power_action"
156
+ server = arg(args, "server")
157
+ signal = arg(args, "signal")
158
+ raise ToolError, "signal must be start, stop, restart or kill" unless %w[start stop restart kill].include?(signal)
159
+
160
+ panel.servers.power(server, signal)
161
+ "Sent #{signal} to #{server}."
162
+ when "send_command"
163
+ server = arg(args, "server")
164
+ command = arg(args, "command")
165
+ panel.servers.command(server, command)
166
+ "Sent to #{server}: #{command}"
167
+ when "read_console" then read_console(arg(args, "server"), args["lines"])
168
+ when "list_files"
169
+ files = panel.files.list(arg(args, "server"), (args["directory"] || "/").to_s)
170
+ files.map { |f| f.slice("name", "is_file", "size", "mimetype", "modified_at") }
171
+ when "read_file"
172
+ text = panel.files.read(arg(args, "server"), arg(args, "path"))
173
+ return text if text.length <= MAX_FILE_CHARS
174
+
175
+ "#{text[-MAX_FILE_CHARS..]}\n[showing the last #{MAX_FILE_CHARS} characters of #{text.length}]"
176
+ when "git_deploy" then panel.git.deploy(arg(args, "server"))
177
+ when "list_backups" then panel.backups.list(arg(args, "server")).to_a
178
+ end
179
+ end
180
+
181
+ def read_console(server, lines)
182
+ history = @client.panel.console.history(server)
183
+ return "#{server} has no console output: it is offline, or has printed nothing since it started." if history.empty?
184
+
185
+ count = [Integer(lines || 100, exception: false) || 100, 1].max
186
+ history.last(count).join("\n")[-MAX_FILE_CHARS..] || history.last(count).join("\n")
187
+ end
188
+
189
+ def error_reply(id, code, message)
190
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SillyDev
4
+ module Monitoring
5
+ # Seconds per aggregation window. Each series sends at most one point per window.
6
+ WINDOW = 10
7
+
8
+ # Upper bounds (ms) of the first 15 histogram buckets; the 16th is everything above 60000.
9
+ BOUNDS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000].freeze
10
+ BUCKETS = BOUNDS.length + 1
11
+
12
+ # Client-side pre-aggregation of metrics into fixed windows (see feathers/docs/ingest.md). Holds
13
+ # one value per (window, name, type, tags). Not thread-safe: the client locks around it.
14
+ class Aggregator
15
+ Histogram = Struct.new(:count, :sum, :min, :max, :buckets) do # rubocop:disable Lint/StructNewOverride
16
+ def add(value)
17
+ self.count += 1
18
+ self.sum += value
19
+ self.min = value if value < min
20
+ self.max = value if value > max
21
+ buckets[Aggregator.bucket_index(value)] += 1
22
+ end
23
+ end
24
+
25
+ # The first bucket whose upper bound is >= value (the 16th for anything above 60000).
26
+ def self.bucket_index(value)
27
+ BOUNDS.bsearch_index { |bound| bound >= value } || BOUNDS.length
28
+ end
29
+
30
+ attr_accessor :dropped
31
+ attr_reader :max_series
32
+
33
+ def initialize(max_series = 2000)
34
+ @max_series = max_series
35
+ @series = {}
36
+ @dropped = 0
37
+ end
38
+
39
+ def size
40
+ @series.size
41
+ end
42
+
43
+ def counter(ts, name, value, tags)
44
+ key = slot(ts, name, "counter", tags)
45
+ @series[key] = (@series[key] || 0) + value if key
46
+ end
47
+
48
+ def gauge(ts, name, value, tags)
49
+ key = slot(ts, name, "gauge", tags)
50
+ @series[key] = value if key
51
+ end
52
+
53
+ def histogram(ts, name, value, tags)
54
+ key = slot(ts, name, "histogram", tags)
55
+ return unless key
56
+
57
+ hist = @series[key]
58
+ hist = @series[key] = Histogram.new(0, 0.0, Float::INFINITY, -Float::INFINITY, Array.new(BUCKETS, 0)) unless hist.is_a?(Histogram)
59
+ hist.add(value)
60
+ end
61
+
62
+ # Removes and returns the points of every window that has ended by now (all when nil).
63
+ def drain(now = nil)
64
+ cutoff = now.nil? ? nil : (now.to_f / WINDOW).floor * WINDOW
65
+ out = []
66
+ @series.keys.sort_by(&:first).each do |key|
67
+ start, name, kind, tags = key
68
+ next if cutoff && start >= cutoff
69
+
70
+ value = @series.delete(key)
71
+ item = { "name" => name, "type" => kind, "ts" => start }
72
+ if kind == "histogram"
73
+ item.merge!("count" => value.count, "sum" => num(value.sum), "min" => num(value.min),
74
+ "max" => num(value.max), "buckets" => value.buckets.dup)
75
+ else
76
+ item["value"] = num(value)
77
+ end
78
+ item["tags"] = tags.to_h unless tags.empty?
79
+ out << item
80
+ end
81
+ out
82
+ end
83
+
84
+ def clear
85
+ @series.clear
86
+ end
87
+
88
+ private
89
+
90
+ def slot(ts, name, kind, tags)
91
+ key = [(ts.to_f / WINDOW).floor * WINDOW, name, kind, tags]
92
+ if !@series.key?(key) && @series.size >= @max_series
93
+ @dropped += 1
94
+ return nil
95
+ end
96
+ key
97
+ end
98
+
99
+ def num(value)
100
+ return value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
101
+ return value.round(6) if value.is_a?(Float)
102
+
103
+ value
104
+ end
105
+ end
106
+ end
107
+ end