ultimate_json_rpc 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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Core
5
+ module Response
6
+ def self.success(result, id)
7
+ {
8
+ "jsonrpc" => "2.0",
9
+ "result" => result,
10
+ "id" => id
11
+ }
12
+ end
13
+
14
+ def self.error(code, id, data: nil, message: nil)
15
+ err = {
16
+ "code" => code,
17
+ "message" => message || ERROR_MESSAGES.fetch(code, "Unknown error")
18
+ }
19
+ err["data"] = data unless data.nil?
20
+ {
21
+ "jsonrpc" => "2.0",
22
+ "error" => err,
23
+ "id" => id
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ class Docs
6
+ def initialize(server, json: JSON)
7
+ @server = server
8
+ @json = json
9
+ end
10
+
11
+ def to_markdown
12
+ lines = []
13
+ lines << title_section
14
+ lines << methods_section
15
+ lines << errors_section
16
+ lines.compact.join("\n")
17
+ end
18
+
19
+ private
20
+
21
+ def discover
22
+ @discover ||= begin
23
+ request = { "jsonrpc" => "2.0", "method" => "rpc.discover", "id" => 1 }
24
+ result = @json.parse(@server.handle(@json.generate(request)))
25
+ raise "rpc.discover returned an error: #{result["error"]&.inspect}" unless result.key?("result")
26
+
27
+ result["result"]
28
+ end
29
+ end
30
+
31
+ def title_section
32
+ info = discover["info"]
33
+ return "# API Reference\n" unless info
34
+
35
+ lines = []
36
+ lines << "# #{info["title"] || "API Reference"}\n"
37
+ lines << "#{info["description"]}\n" if info["description"]
38
+ lines << "**Version:** #{info["version"]}\n" if info["version"]
39
+ lines.join("\n")
40
+ end
41
+
42
+ def methods_section
43
+ methods = discover["methods"]
44
+ return nil if methods.nil? || methods.empty?
45
+
46
+ lines = ["## Methods\n"]
47
+ methods.each { |m| lines << method_entry(m) }
48
+ lines.join("\n")
49
+ end
50
+
51
+ def method_entry(method)
52
+ lines = ["### `#{method["name"]}`\n"]
53
+ append_method_metadata(lines, method)
54
+ lines.join("\n")
55
+ end
56
+
57
+ def append_method_metadata(lines, method)
58
+ lines << "#{method["description"]}\n" if method["description"]
59
+ lines << "*Deprecated: #{deprecation_text(method["deprecated"])}*\n" if method["deprecated"]
60
+ lines << params_table(method["params"]) if method["params"]&.any?
61
+ lines << return_section(method["result"]) if method["result"]
62
+ end
63
+
64
+ def deprecation_text(value)
65
+ value == true ? "This method is deprecated." : value.to_s
66
+ end
67
+
68
+ def params_table(params)
69
+ lines = ["**Parameters:**\n"]
70
+ lines << "| Name | Required | Type |"
71
+ lines << "|------|----------|------|"
72
+ params.each do |p|
73
+ required = p["required"] ? "Yes" : "No"
74
+ type = escape_cell(p.dig("schema", "type") || "-")
75
+ lines << "| `#{escape_cell(p["name"])}` | #{required} | #{type} |"
76
+ end
77
+ lines << ""
78
+ lines.join("\n")
79
+ end
80
+
81
+ def return_section(result)
82
+ type = result["type"] || result.dig("schema", "type")
83
+ return nil unless type
84
+
85
+ "**Returns:** `#{type}`\n"
86
+ end
87
+
88
+ def escape_cell(text)
89
+ text.to_s.gsub("|", "\\|").gsub(/\r?\n/, " ")
90
+ end
91
+
92
+ def errors_section
93
+ errors = discover.dig("components", "errors")
94
+ return nil unless errors&.any?
95
+
96
+ lines = ["## Error Codes\n"]
97
+ lines << "| Code | Name | Description |"
98
+ lines << "|------|------|-------------|"
99
+ errors.each do |e|
100
+ lines << "| #{e["code"]} | #{escape_cell(e["message"])} | #{escape_cell(e["data"] || "-")} |"
101
+ end
102
+ lines << ""
103
+ lines.join("\n")
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ class Logging
6
+ def initialize(server, logger, level: :info)
7
+ server.on(:response) do |request, _result, duration|
8
+ logger.public_send(level, "UltimateJsonRpc") do
9
+ "#{request.method_name} (#{format("%.1f", duration * 1000)}ms)"
10
+ end
11
+ end
12
+
13
+ server.on(:error) do |request, error, duration|
14
+ logger.error("UltimateJsonRpc") do
15
+ "#{request.method_name} #{error.class} (#{format("%.1f", duration * 1000)}ms)"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../transport/stdio"
4
+
5
+ module UltimateJsonRpc
6
+ module Extras
7
+ class MCP
8
+ PROTOCOL_VERSION = "2024-11-05"
9
+
10
+ def initialize(server, input: $stdin, output: $stdout, json: JSON)
11
+ @app_server = server
12
+ @app_server.freeze
13
+ @json = json
14
+ @methods_cache = server.methods_info
15
+ @methods_index = @methods_cache.to_h { |m| [m["name"], m] }
16
+ @mcp_server = build_mcp_server
17
+ @input = input
18
+ @output = output
19
+ @stdio = nil
20
+ @call_id = 0
21
+ end
22
+
23
+ def run
24
+ @stdio = Transport::Stdio.new(@mcp_server, input: @input, output: @output)
25
+ @stdio.run
26
+ end
27
+
28
+ def stop
29
+ @stdio&.stop
30
+ end
31
+
32
+ def running?
33
+ @stdio&.running? || false
34
+ end
35
+
36
+ private
37
+
38
+ def build_mcp_server
39
+ mcp = Server.new(name: @app_server.name, version: @app_server.version, json: @json)
40
+ mcp.expose_method("initialize") { mcp_initialize }
41
+ mcp.expose_method("notifications/initialized") { nil }
42
+ mcp.expose_method("tools/list") { mcp_tools_list }
43
+ mcp.expose_method("tools/call") { |name:, arguments: {}| mcp_tools_call(name, arguments) }
44
+ mcp
45
+ end
46
+
47
+ def mcp_initialize
48
+ {
49
+ "protocolVersion" => PROTOCOL_VERSION,
50
+ "capabilities" => { "tools" => {} },
51
+ "serverInfo" => {
52
+ "name" => @app_server.name || "UltimateJsonRpc MCP Server",
53
+ "version" => @app_server.version || "0.0.0"
54
+ }
55
+ }
56
+ end
57
+
58
+ def mcp_tools_list
59
+ { "tools" => @methods_cache.map { |m| to_mcp_tool(m) } }
60
+ end
61
+
62
+ def mcp_tools_call(name, arguments)
63
+ request = build_call_request(name, arguments)
64
+ raw = @app_server.handle_parsed(request)
65
+ unless raw
66
+ return { "content" => [{ "type" => "text", "text" => "No response from server" }], "isError" => true }
67
+ end
68
+
69
+ response = @json.parse(raw)
70
+ format_call_response(response)
71
+ rescue StandardError => e
72
+ { "content" => [{ "type" => "text", "text" => e.message }], "isError" => true }
73
+ end
74
+
75
+ def to_mcp_tool(method_info)
76
+ tool = { "name" => method_info["name"] }
77
+ tool["description"] = method_info["description"] if method_info["description"]
78
+ tool["inputSchema"] = method_info["params"] ? build_input_schema(method_info["params"]) : { "type" => "object" }
79
+ tool
80
+ end
81
+
82
+ def build_input_schema(params)
83
+ schema = { "type" => "object" }
84
+ properties = {}
85
+ required = []
86
+
87
+ params.each do |p|
88
+ next if p["variadic"]
89
+
90
+ properties[p["name"]] = p.fetch("schema", {})
91
+ required << p["name"] if p["required"]
92
+ end
93
+
94
+ schema["properties"] = properties unless properties.empty?
95
+ schema["required"] = required unless required.empty?
96
+ schema
97
+ end
98
+
99
+ def build_call_request(name, arguments)
100
+ @call_id += 1
101
+ request = { "jsonrpc" => "2.0", "method" => name, "id" => "mcp-#{@call_id}" }
102
+ return request if arguments.nil? || arguments.empty?
103
+
104
+ method_info = @methods_index[name]
105
+ request["params"] = convert_arguments(arguments, method_info)
106
+ request
107
+ end
108
+
109
+ def convert_arguments(arguments, method_info)
110
+ params = method_info&.dig("params")
111
+ return arguments unless params
112
+
113
+ arguments = arguments.transform_keys(&:to_s)
114
+ return arguments if params.any? { |p| p["keyword"] }
115
+
116
+ convert_to_positional(arguments, params)
117
+ end
118
+
119
+ def convert_to_positional(arguments, params)
120
+ params.reject { |p| p["variadic"] }.map do |p|
121
+ validate_required_argument!(arguments, p)
122
+ arguments[p["name"]]
123
+ end
124
+ end
125
+
126
+ def validate_required_argument!(arguments, param)
127
+ name = param["name"]
128
+ return if name.empty? || !param["required"] || arguments.key?(name)
129
+
130
+ raise ArgumentError, "missing required argument: #{name}"
131
+ end
132
+
133
+ def format_call_response(response)
134
+ if response&.key?("error")
135
+ { "content" => [{ "type" => "text", "text" => response.dig("error", "message").to_s }], "isError" => true }
136
+ else
137
+ result = response&.fetch("result", nil)
138
+ text = result.is_a?(String) ? result : @json.generate(result)
139
+ { "content" => [{ "type" => "text", "text" => text }] }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ class Profiler
6
+ DEFAULT_MAX_SAMPLES = 10_000
7
+
8
+ def initialize(server, max_samples: DEFAULT_MAX_SAMPLES)
9
+ @data = {}
10
+ @mutex = Mutex.new
11
+ @max_samples = max_samples
12
+ attach(server)
13
+ end
14
+
15
+ def [](method_name)
16
+ entry_copy = @mutex.synchronize do
17
+ entry = @data[method_name]
18
+ return nil unless entry
19
+
20
+ snapshot_entry(entry)
21
+ end
22
+ build_stats(entry_copy)
23
+ end
24
+
25
+ def tracked_methods
26
+ @mutex.synchronize { @data.keys.sort }
27
+ end
28
+ alias method_names tracked_methods
29
+
30
+ def stats
31
+ entries = @mutex.synchronize { @data.to_h { |name, entry| [name, snapshot_entry(entry)] } }
32
+ entries.transform_values { |entry| build_stats(entry) }
33
+ end
34
+
35
+ def reset
36
+ @mutex.synchronize { @data.clear }
37
+ end
38
+
39
+ private
40
+
41
+ def attach(server)
42
+ server.on(:response) { |request, _result, duration| record(request.method_name, duration) }
43
+ server.on(:error) { |request, _error, duration| record(request.method_name, duration) }
44
+ end
45
+
46
+ def record(method_name, duration)
47
+ @mutex.synchronize do
48
+ entry = (@data[method_name] ||= { count: 0, total: 0.0, min: Float::INFINITY, max: 0.0, durations: [] })
49
+ entry[:count] += 1
50
+ entry[:total] += duration
51
+ entry[:min] = duration if duration < entry[:min]
52
+ entry[:max] = duration if duration > entry[:max]
53
+ entry[:durations] << duration
54
+ entry[:durations].shift if entry[:durations].size > @max_samples
55
+ end
56
+ end
57
+
58
+ def snapshot_entry(entry)
59
+ { count: entry[:count], total: entry[:total], min: entry[:min], max: entry[:max],
60
+ durations: entry[:durations].dup }
61
+ end
62
+
63
+ def build_stats(entry)
64
+ sorted = entry[:durations].sort
65
+ timing_stats(entry).merge(percentile_stats(sorted))
66
+ end
67
+
68
+ def timing_stats(entry)
69
+ avg = entry[:count].zero? ? 0.0 : entry[:total] / entry[:count]
70
+ { count: entry[:count], total: entry[:total].round(6),
71
+ min: entry[:min].round(6), max: entry[:max].round(6), avg: avg.round(6),
72
+ samples: entry[:durations].size }
73
+ end
74
+
75
+ def percentile_stats(sorted)
76
+ { p50: percentile(sorted, 50), p95: percentile(sorted, 95), p99: percentile(sorted, 99) }
77
+ end
78
+
79
+ def percentile(sorted, pct)
80
+ return 0.0 if sorted.empty?
81
+
82
+ k = ((pct / 100.0) * sorted.size).ceil - 1
83
+ sorted[[k, 0].max].round(6)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ class RateLimiter
6
+ def initialize(server, max:, period:, key: nil, code: 429, message: "Rate limit exceeded", only: nil, except: nil)
7
+ validate_code!(code)
8
+ @max = max
9
+ @period = period
10
+ @key_fn = build_key_fn(key)
11
+ @code = code
12
+ @message = message
13
+ @windows = {}
14
+ @mutex = Mutex.new
15
+ server.use(only:, except:) do |request, next_call|
16
+ check!(@key_fn.call(request))
17
+ next_call.call
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def build_key_fn(key)
24
+ case key
25
+ when nil then ->(_) { :global }
26
+ when Symbol then ->(req) { req.context.fetch(key, :unknown) }
27
+ when Proc then key
28
+ else raise ArgumentError, "key must be nil, a Symbol, or a Proc"
29
+ end
30
+ end
31
+
32
+ def check!(bucket)
33
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ @mutex.synchronize do
35
+ window = (@windows[bucket] ||= [])
36
+ window.reject! { |t| now - t > @period }
37
+ raise Core::ApplicationError.new(code: @code, message: @message) if window.size >= @max
38
+
39
+ window << now
40
+ evict_stale!(now)
41
+ end
42
+ end
43
+
44
+ def evict_stale!(now)
45
+ @windows.delete_if { |_, w| w.none? { |t| now - t <= @period } } if @windows.size > 20
46
+ end
47
+
48
+ def validate_code!(code)
49
+ raise ArgumentError, "code must be an Integer" unless code.is_a?(Integer)
50
+ return unless code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
51
+
52
+ raise ArgumentError,
53
+ "code #{code} is in the reserved JSON-RPC range (#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ class Recorder
6
+ UNSET = Object.new.freeze
7
+ private_constant :UNSET
8
+
9
+ def initialize(server, output: nil, max_exchanges: nil, json: JSON)
10
+ @exchanges = []
11
+ @max_exchanges = max_exchanges
12
+ @output = output
13
+ @json = json
14
+ @mutex = Mutex.new
15
+ @output_mutex = Mutex.new
16
+ attach(server)
17
+ end
18
+
19
+ def exchanges
20
+ @mutex.synchronize { @exchanges.dup }
21
+ end
22
+
23
+ def clear
24
+ @mutex.synchronize { @exchanges.clear }
25
+ end
26
+
27
+ def size
28
+ @mutex.synchronize { @exchanges.size }
29
+ end
30
+
31
+ private
32
+
33
+ def attach(server)
34
+ server.on(:response) do |request, result, duration|
35
+ record(request, duration, result:)
36
+ end
37
+
38
+ server.on(:error) do |request, error, duration|
39
+ record(request, duration, error: { "class" => error.class.name, "message" => error.message })
40
+ end
41
+ end
42
+
43
+ def record(request, duration, result: UNSET, error: nil)
44
+ entry = { "method" => request.method_name, "params" => request.params,
45
+ "duration" => duration.round(6) }
46
+ entry["result"] = result unless result.equal?(UNSET)
47
+ entry["error"] = error if error
48
+ @mutex.synchronize do
49
+ @exchanges << entry
50
+ @exchanges.shift if @max_exchanges && @exchanges.size > @max_exchanges
51
+ end
52
+ write_output(entry)
53
+ end
54
+
55
+ def write_output(entry)
56
+ return unless @output
57
+
58
+ @output_mutex.synchronize do
59
+ @output.puts(@json.generate(entry))
60
+ @output.flush
61
+ end
62
+ rescue IOError, SystemCallError
63
+ # Output broken; exchange still recorded in memory
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Extras
5
+ module TestHelpers
6
+ def rpc_call(server, method, params: nil, id: 1)
7
+ json = rpc_json_adapter(server)
8
+ request = { "jsonrpc" => "2.0", "method" => method, "id" => id }
9
+ request["params"] = params if params
10
+ json.parse(server.handle(json.generate(request)))
11
+ end
12
+
13
+ def rpc_notify(server, method, params: nil)
14
+ json = rpc_json_adapter(server)
15
+ request = { "jsonrpc" => "2.0", "method" => method }
16
+ request["params"] = params if params
17
+ server.handle(json.generate(request))
18
+ end
19
+
20
+ def rpc_batch(server, *requests)
21
+ json = rpc_json_adapter(server)
22
+ result = server.handle(json.generate(requests))
23
+ result ? json.parse(result) : nil
24
+ end
25
+
26
+ def assert_rpc_success(response, expected: :__not_given__, msg: nil)
27
+ assert response.key?("result"), msg || "Expected success response but got error: #{response["error"]&.inspect}"
28
+ refute response.key?("error"), msg || "Expected no error but got: #{response["error"]&.inspect}"
29
+ return if expected == :__not_given__
30
+
31
+ expected.nil? ? assert_nil(response["result"], msg) : assert_equal(expected, response["result"], msg)
32
+ end
33
+
34
+ def assert_rpc_error(response, code: nil, message: nil, msg: nil)
35
+ assert response.key?("error"), msg || "Expected error response but got result: #{response["result"]&.inspect}"
36
+ refute response.key?("result"), msg || "Expected no result but got: #{response["result"]&.inspect}"
37
+ assert_equal code, response["error"]["code"], msg if code
38
+ assert_equal message, response["error"]["message"], msg if message
39
+ end
40
+
41
+ def assert_rpc_notification(server, method, params: nil, msg: nil)
42
+ result = rpc_notify(server, method, params: params)
43
+ assert_nil result, msg || "Expected nil for notification but got: #{result.inspect}"
44
+ end
45
+
46
+ private
47
+
48
+ def rpc_json_adapter(server)
49
+ server.json_adapter
50
+ end
51
+ end
52
+ end
53
+ end